diff --git a/.github/ISSUE_TEMPLATE/custom.md b/.github/ISSUE_TEMPLATE/custom.md index d4329f7..7690a5a 100644 --- a/.github/ISSUE_TEMPLATE/custom.md +++ b/.github/ISSUE_TEMPLATE/custom.md @@ -14,6 +14,7 @@ _Please fill in as many fields as you can so we can swiftly investigate_ #### Context _Federation_: +_Fedi app version_: _Phone OS_: _User Name_" diff --git a/.github/workflows/bump-version-native-ui.yml b/.github/workflows/bump-version-native-ui.yml index e9404fb..b18cb04 100644 --- a/.github/workflows/bump-version-native-ui.yml +++ b/.github/workflows/bump-version-native-ui.yml @@ -1,15 +1,6 @@ name: Bump version - Native UI on: workflow_dispatch: - push: - branches: - - 'release/**.**' - - '!release/**.**.**' - paths: - - 'bridge/**' - - 'ui/native/**' - - 'ui/common/**' - - '!ui/native/ios/**' # Don't run for changes in /ios folder jobs: bump-version: permissions: @@ -23,7 +14,7 @@ jobs: uses: actions/checkout@v4 - name: Install Nix - uses: cachix/install-nix-action@v26 + uses: cachix/install-nix-action@v30 with: nix_path: nixpkgs=channel:nixos-22.05 diff --git a/.github/workflows/deploy-public-apk-to-github.yml b/.github/workflows/deploy-public-apk-to-github.yml index c48c573..44b1799 100644 --- a/.github/workflows/deploy-public-apk-to-github.yml +++ b/.github/workflows/deploy-public-apk-to-github.yml @@ -1,7 +1,7 @@ -name: Deploy Public APK to Github +name: Deploy Public APK on: release: - # these events will not fire for draft releases + # will not fire for draft releases types: [published, edited] jobs: @@ -11,14 +11,45 @@ jobs: - name: Checkout script in repo uses: actions/checkout@v4 - - name: Run publish-release + - name: Prepare APK for Vercel deployment uses: actions/github-script@v7 - id: publish-release + id: prepare-apk env: - RELEASE_ID: ${{ github.event.release.id }} + RELEASE_ID: ${{ github.event.release.id || vars.TEST_RELEASE_ID }} + SOURCE_FEDI_ORG: ${{ vars.SOURCE_FEDI_ORG }} + SOURCE_FEDI_REPO: ${{ vars.SOURCE_FEDI_REPO }} with: - github-token: ${{ secrets.DEPLOY_APK_ACCESS_TOKEN }} + github-token: ${{ secrets.DOWNLOAD_APK_ACCESS_TOKEN }} result-encoding: string script: | - const script = require('./scripts/ci/deploy-public-apk.js') + const script = require('./scripts/ci/prepare-apk.js') + await script({github, context, core}) + + - uses: cachix/install-nix-action@v30 + with: + nix_path: nixpkgs=channel:nixos-23.05 + + - uses: cachix/cachix-action@v15 + with: + name: fedibtc + authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' + + - name: Deploy APK to Vercel + id: deploy + env: + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_APK_PROJECT_ID }} + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + run: nix develop .#vercel -c ./scripts/ci/vercel-apk.sh + + - name: Update public repo + uses: actions/github-script@v7 + env: + PUBLIC_APK_URL: ${{ vars.PUBLIC_APK_URL }} + PUBLIC_FEDI_ORG: ${{ vars.PUBLIC_FEDI_ORG }} + PUBLIC_FEDI_REPO: ${{ vars.PUBLIC_FEDI_REPO }} + with: + github-token: ${{ secrets.PUBLISH_APK_ACCESS_TOKEN }} + script: | + const script = require('./scripts/ci/publish-release.js') await script({github, context, core}) diff --git a/.github/workflows/deploy-to-gp-internal-testing-nightly.yml b/.github/workflows/deploy-to-gp-internal-testing-nightly.yml index 9f2ca2e..5681b29 100644 --- a/.github/workflows/deploy-to-gp-internal-testing-nightly.yml +++ b/.github/workflows/deploy-to-gp-internal-testing-nightly.yml @@ -12,7 +12,7 @@ jobs: - name: Checkout repo uses: actions/checkout@v4 - - uses: cachix/install-nix-action@v26 + - uses: cachix/install-nix-action@v30 with: nix_path: nixpkgs=channel:nixos-22.05 diff --git a/.github/workflows/deploy-to-gp-internal-testing.yml b/.github/workflows/deploy-to-gp-internal-testing.yml index 2b00126..faf6739 100644 --- a/.github/workflows/deploy-to-gp-internal-testing.yml +++ b/.github/workflows/deploy-to-gp-internal-testing.yml @@ -12,7 +12,7 @@ jobs: - name: Checkout repo uses: actions/checkout@v4 - - uses: cachix/install-nix-action@v26 + - uses: cachix/install-nix-action@v30 with: nix_path: nixpkgs=channel:nixos-22.05 diff --git a/.github/workflows/deploy-to-testflight-nightly.yml b/.github/workflows/deploy-to-testflight-nightly.yml index 89f5de0..86f3924 100644 --- a/.github/workflows/deploy-to-testflight-nightly.yml +++ b/.github/workflows/deploy-to-testflight-nightly.yml @@ -12,7 +12,7 @@ jobs: - name: Checkout repo uses: actions/checkout@v4 - - uses: cachix/install-nix-action@v26 + - uses: cachix/install-nix-action@v30 with: nix_path: nixpkgs=channel:nixos-22.05 diff --git a/.github/workflows/deploy-to-testflight.yml b/.github/workflows/deploy-to-testflight.yml index 8fb45dd..f713035 100644 --- a/.github/workflows/deploy-to-testflight.yml +++ b/.github/workflows/deploy-to-testflight.yml @@ -1,20 +1,10 @@ name: Deploy to TestFlight on: workflow_dispatch: - pull_request: - types: - - closed - branches: - - 'master' + workflow_call: jobs: release-ios: - if: > - (github.event_name == 'workflow_dispatch') || - ( - github.event.pull_request.merged == true && - startsWith(github.head_ref, 'release/') - ) name: Build iOS app and upload to TestFlight timeout-minutes: 60 runs-on: [self-hosted, macos, arm64] @@ -22,7 +12,7 @@ jobs: - name: Checkout repo uses: actions/checkout@v4 - - uses: cachix/install-nix-action@v26 + - uses: cachix/install-nix-action@v30 with: nix_path: nixpkgs=channel:nixos-22.05 diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 86f28a9..083bab4 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -77,7 +77,7 @@ jobs: timeout-minutes: 5 steps: - uses: actions/checkout@v4 - - uses: cachix/install-nix-action@v26 + - uses: cachix/install-nix-action@v30 with: nix_path: nixpkgs=channel:nixos-22.05 - uses: cachix/cachix-action@v15 @@ -116,7 +116,7 @@ jobs: needs.precheck.outputs.IS_PUSH_TO_TAG == '1' steps: - uses: actions/checkout@v4 - - uses: cachix/install-nix-action@v26 + - uses: cachix/install-nix-action@v30 with: nix_path: nixpkgs=channel:nixos-22.05 - uses: cachix/cachix-action@v15 @@ -154,7 +154,7 @@ jobs: timeout-minutes: 60 steps: - uses: actions/checkout@v4 - - uses: cachix/install-nix-action@v26 + - uses: cachix/install-nix-action@v30 with: nix_path: nixpkgs=channel:nixos-22.05 - uses: cachix/cachix-action@v15 @@ -174,6 +174,9 @@ jobs: - name: Build fedi-api-types run: nix build -L .#ci.fedi-api-types + - name: Build fedi-ffi + run: nix build -L .#ci.fedi-ffi + - name: Build fedi-wasm run: nix build -L .#wasm32-unknown.ci.fedi-wasm @@ -189,6 +192,7 @@ jobs: - name: Build fedimint-dbtool run: nix build -L .#fedimint-dbtool + # Since cargo-udeps supports only `test` profile and thus can't re-use any # other derivations, we can start it in separate workflow udeps: @@ -202,7 +206,7 @@ jobs: timeout-minutes: 60 steps: - uses: actions/checkout@v4 - - uses: cachix/install-nix-action@v26 + - uses: cachix/install-nix-action@v30 with: nix_path: nixpkgs=channel:nixos-22.05 - uses: cachix/cachix-action@v15 @@ -223,7 +227,7 @@ jobs: timeout-minutes: 60 steps: - uses: actions/checkout@v4 - - uses: cachix/install-nix-action@v26 + - uses: cachix/install-nix-action@v30 with: nix_path: nixpkgs=channel:nixos-22.05 - uses: cachix/cachix-action@v15 @@ -234,6 +238,9 @@ jobs: - name: Build Android bridge artifacts run: nix develop --ignore-environment --command env HOME="$HOME" BUILD_ALL_BRIDGE_TARGETS=1 CARGO_PROFILE=ci scripts/ci/run-in-fs-dir-cache.sh build-bridge-android ./scripts/bridge/build-bridge-android.sh + - name: Build fedi-ffi + run: nix build -L .#aarch64-android.ci.fedi-ffi + build-ios: name: 'Build - macos' needs: precheck @@ -244,7 +251,7 @@ jobs: timeout-minutes: 60 steps: - uses: actions/checkout@v4 - - uses: cachix/install-nix-action@v26 + - uses: cachix/install-nix-action@v30 with: nix_path: nixpkgs=channel:nixos-22.05 - uses: cachix/cachix-action@v15 @@ -284,7 +291,7 @@ jobs: - uses: actions/checkout@v4 if: github.ref == 'refs/heads/master' || matrix.build-in-pr - - uses: cachix/install-nix-action@v26 + - uses: cachix/install-nix-action@v30 if: github.ref == 'refs/heads/master' || matrix.build-in-pr with: nix_path: nixpkgs=channel:nixos-22.05 @@ -316,7 +323,7 @@ jobs: - name: Prepare uses: ./.github/actions/prepare - uses: actions/checkout@v4 - - uses: cachix/install-nix-action@v26 + - uses: cachix/install-nix-action@v30 with: nix_path: nixpkgs=channel:nixos-23.11 extra_nix_config: | diff --git a/.github/workflows/release-nightly.yml b/.github/workflows/release-nightly.yml index eb3137c..65bce7e 100644 --- a/.github/workflows/release-nightly.yml +++ b/.github/workflows/release-nightly.yml @@ -1,7 +1,7 @@ name: Release Fedi Nightly on: workflow_dispatch: - schedule: + schedule: # runs every day at 5am UTC - cron: '0 5 * * *' jobs: @@ -14,7 +14,7 @@ jobs: with: submodules: true - - uses: cachix/install-nix-action@v26 + - uses: cachix/install-nix-action@v30 with: nix_path: nixpkgs=channel:nixos-22.05 diff --git a/.github/workflows/release-production.yml b/.github/workflows/release-production.yml new file mode 100644 index 0000000..2b90b63 --- /dev/null +++ b/.github/workflows/release-production.yml @@ -0,0 +1,75 @@ +name: Release Fedi (Production) +on: + workflow_dispatch: + +jobs: + release-production-apk: + name: Build Android APK and upload to GitHub draft release + runs-on: [self-hosted, linux] + steps: + - name: Checkout repo + uses: actions/checkout@v4 + with: + submodules: true + + - uses: cachix/install-nix-action@v30 + with: + nix_path: nixpkgs=channel:nixos-22.05 + + - uses: cachix/cachix-action@v15 + with: + name: fedibtc + authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' + + - name: Build bridge + run: nix develop -c env BUILD_ALL_BRIDGE_TARGETS=1 CARGO_PROFILE=release scripts/ci/run-in-fs-dir-cache.sh build-bridge-android ./scripts/bridge/build-bridge-android.sh + + - name: Build UI dependencies + run: nix develop -c ./scripts/ui/build-deps.sh + + - name: Prep for APK build + id: prep-apk + run: nix develop -c ./scripts/ci/prep-apk.sh + + - name: Build APK + env: + APK_PATH: ${{ steps.prep-apk.outputs.APK_PATH }} + # Consider: should we even have separate steps above for this? + BUILD_BRIDGE: 0 + BUILD_UI_DEPS: 0 + run: nix develop -c ./scripts/ui/build-production-apk.sh + + - name: Verify bridge hash + env: + APK_PATH: ${{ steps.prep-apk.outputs.APK_PATH }} + run: | + set -euo pipefail + + bash ./scripts/ci/verify-bridge-hash.sh "$APK_PATH" "${{ github.sha }}" + + - name: Create draft GitHub release with APK + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ steps.prep-apk.outputs.APK_VERSION }} + name: 'Fedi v${{ steps.prep-apk.outputs.APK_VERSION }}' + body: 'Built from commit: ${{ github.sha }}' + draft: true + append_body: true + files: ${{ steps.prep-apk.outputs.APK_PATH }} + + call-deployment-workflow: + name: Trigger deployment to Google Play for Internal Testing + needs: release-production-apk # wait for APK build so we can reuse cached bridge artifacts + uses: ./.github/workflows/deploy-to-gp-internal-testing.yml + secrets: inherit + if: >- + startsWith(github.ref, 'refs/heads/release/') || + startsWith(github.ref, 'refs/heads/master') + + call-deployment-workflow-testflight: + name: Trigger deployment to TestFlight + uses: ./.github/workflows/deploy-to-testflight.yml + secrets: inherit + if: >- + startsWith(github.ref, 'refs/heads/release/') || + startsWith(github.ref, 'refs/heads/master') diff --git a/.github/workflows/test-ui.yml b/.github/workflows/test-ui.yml index 1a1d7b9..32af4ee 100644 --- a/.github/workflows/test-ui.yml +++ b/.github/workflows/test-ui.yml @@ -13,7 +13,7 @@ jobs: - name: Checkout repo uses: actions/checkout@v4 - - uses: cachix/install-nix-action@v26 + - uses: cachix/install-nix-action@v30 with: nix_path: nixpkgs=channel:nixos-23.05 diff --git a/.github/workflows/upload-android-apk.yml b/.github/workflows/upload-android-apk.yml index 0446c39..a01aa6f 100644 --- a/.github/workflows/upload-android-apk.yml +++ b/.github/workflows/upload-android-apk.yml @@ -1,20 +1,9 @@ name: Upload APK to GitHub on: workflow_dispatch: - pull_request: - types: - - closed - branches: - - 'master' jobs: release-android: - if: > - (github.event_name == 'workflow_dispatch') || - ( - github.event.pull_request.merged == true && - startsWith(github.head_ref, 'release/') - ) name: Build Android APK and publish in GitHub draft release runs-on: [self-hosted, linux] steps: @@ -23,7 +12,7 @@ jobs: with: submodules: true - - uses: cachix/install-nix-action@v26 + - uses: cachix/install-nix-action@v30 with: nix_path: nixpkgs=channel:nixos-22.05 @@ -70,14 +59,7 @@ jobs: with: tag_name: v${{ steps.prep-apk.outputs.APK_VERSION }} name: 'v${{ steps.prep-apk.outputs.APK_VERSION }}' - body: 'This release was generated by CI with the latest APK for an upcoming release. Built from commit: ${{ github.sha }}' + body: 'Built from commit: ${{ github.sha }}' draft: true + append_body: true files: ${{ steps.prep-apk.outputs.APK_PATH }} - - call-deployment-workflow: - name: Trigger deployment to Google Play for Internal Testing - needs: release-android - uses: ./.github/workflows/deploy-to-gp-internal-testing.yml - secrets: inherit - if: >- - startsWith(github.head_ref, 'release/') diff --git a/.github/workflows/vercel-preview.yml b/.github/workflows/vercel-preview.yml index 32b1db6..c710787 100644 --- a/.github/workflows/vercel-preview.yml +++ b/.github/workflows/vercel-preview.yml @@ -22,7 +22,7 @@ jobs: - name: Checkout repo uses: actions/checkout@v4 - - uses: cachix/install-nix-action@v26 + - uses: cachix/install-nix-action@v30 with: nix_path: nixpkgs=channel:nixos-23.05 diff --git a/.github/workflows/vercel-prod.yml b/.github/workflows/vercel-prod.yml index 7771c21..2aaa091 100644 --- a/.github/workflows/vercel-prod.yml +++ b/.github/workflows/vercel-prod.yml @@ -22,7 +22,7 @@ jobs: - name: Checkout repo uses: actions/checkout@v4 - - uses: cachix/install-nix-action@v26 + - uses: cachix/install-nix-action@v30 with: nix_path: nixpkgs=channel:nixos-23.05 diff --git a/Cargo.lock b/Cargo.lock index d279d39..81d8358 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1507,8 +1507,8 @@ dependencies = [ [[package]] name = "devimint" -version = "0.4.2-rc.1" -source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.2-rc.1-fed-3#bc288b405705b3477f840cd2e5e03ff35174d084" +version = "0.4.3-rc.2" +source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.3-rc.2-fed4#eb40053b816c86192fbfd5e2a61f6d9c1670d789" dependencies = [ "anyhow", "axum 0.7.5", @@ -1886,6 +1886,7 @@ dependencies = [ "fedimint-ln-client", "fedimint-ln-common", "fedimint-logging", + "fedimint-meta-client", "fedimint-mint-client", "fedimint-rocksdb", "fedimint-threshold-crypto", @@ -2049,8 +2050,8 @@ dependencies = [ [[package]] name = "fedimint-aead" -version = "0.4.2-rc.1" -source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.2-rc.1-fed-3#bc288b405705b3477f840cd2e5e03ff35174d084" +version = "0.4.3-rc.2" +source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.3-rc.2-fed4#eb40053b816c86192fbfd5e2a61f6d9c1670d789" dependencies = [ "anyhow", "argon2", @@ -2082,8 +2083,8 @@ dependencies = [ [[package]] name = "fedimint-api-client" -version = "0.4.2-rc.1" -source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.2-rc.1-fed-3#bc288b405705b3477f840cd2e5e03ff35174d084" +version = "0.4.3-rc.2" +source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.3-rc.2-fed4#eb40053b816c86192fbfd5e2a61f6d9c1670d789" dependencies = [ "anyhow", "async-lock", @@ -2114,8 +2115,8 @@ dependencies = [ [[package]] name = "fedimint-bip39" -version = "0.4.2-rc.1" -source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.2-rc.1-fed-3#bc288b405705b3477f840cd2e5e03ff35174d084" +version = "0.4.3-rc.2" +source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.3-rc.2-fed4#eb40053b816c86192fbfd5e2a61f6d9c1670d789" dependencies = [ "bip39", "fedimint-client", @@ -2125,8 +2126,8 @@ dependencies = [ [[package]] name = "fedimint-bitcoind" -version = "0.4.2-rc.1" -source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.2-rc.1-fed-3#bc288b405705b3477f840cd2e5e03ff35174d084" +version = "0.4.3-rc.2" +source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.3-rc.2-fed4#eb40053b816c86192fbfd5e2a61f6d9c1670d789" dependencies = [ "anyhow", "async-trait", @@ -2147,16 +2148,16 @@ dependencies = [ [[package]] name = "fedimint-build" -version = "0.4.2-rc.1" -source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.2-rc.1-fed-3#bc288b405705b3477f840cd2e5e03ff35174d084" +version = "0.4.3-rc.2" +source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.3-rc.2-fed4#eb40053b816c86192fbfd5e2a61f6d9c1670d789" dependencies = [ "serde_json", ] [[package]] name = "fedimint-cli" -version = "0.4.2-rc.1" -source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.2-rc.1-fed-3#bc288b405705b3477f840cd2e5e03ff35174d084" +version = "0.4.3-rc.2" +source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.3-rc.2-fed4#eb40053b816c86192fbfd5e2a61f6d9c1670d789" dependencies = [ "anyhow", "async-trait", @@ -2201,8 +2202,8 @@ dependencies = [ [[package]] name = "fedimint-client" -version = "0.4.2-rc.1" -source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.2-rc.1-fed-3#bc288b405705b3477f840cd2e5e03ff35174d084" +version = "0.4.3-rc.2" +source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.3-rc.2-fed4#eb40053b816c86192fbfd5e2a61f6d9c1670d789" dependencies = [ "anyhow", "aquamarine", @@ -2233,8 +2234,8 @@ dependencies = [ [[package]] name = "fedimint-core" -version = "0.4.2-rc.1" -source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.2-rc.1-fed-3#bc288b405705b3477f840cd2e5e03ff35174d084" +version = "0.4.3-rc.2" +source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.3-rc.2-fed4#eb40053b816c86192fbfd5e2a61f6d9c1670d789" dependencies = [ "anyhow", "async-lock", @@ -2287,8 +2288,8 @@ dependencies = [ [[package]] name = "fedimint-derive" -version = "0.4.2-rc.1" -source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.2-rc.1-fed-3#bc288b405705b3477f840cd2e5e03ff35174d084" +version = "0.4.3-rc.2" +source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.3-rc.2-fed4#eb40053b816c86192fbfd5e2a61f6d9c1670d789" dependencies = [ "itertools 0.12.1", "proc-macro2", @@ -2298,8 +2299,8 @@ dependencies = [ [[package]] name = "fedimint-derive-secret" -version = "0.4.2-rc.1" -source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.2-rc.1-fed-3#bc288b405705b3477f840cd2e5e03ff35174d084" +version = "0.4.3-rc.2" +source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.3-rc.2-fed4#eb40053b816c86192fbfd5e2a61f6d9c1670d789" dependencies = [ "anyhow", "bitcoin_hashes 0.11.0", @@ -2312,16 +2313,16 @@ dependencies = [ [[package]] name = "fedimint-hkdf" -version = "0.4.2-rc.1" -source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.2-rc.1-fed-3#bc288b405705b3477f840cd2e5e03ff35174d084" +version = "0.4.3-rc.2" +source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.3-rc.2-fed4#eb40053b816c86192fbfd5e2a61f6d9c1670d789" dependencies = [ "bitcoin_hashes 0.12.0", ] [[package]] name = "fedimint-ln-client" -version = "0.4.2-rc.1" -source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.2-rc.1-fed-3#bc288b405705b3477f840cd2e5e03ff35174d084" +version = "0.4.3-rc.2" +source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.3-rc.2-fed4#eb40053b816c86192fbfd5e2a61f6d9c1670d789" dependencies = [ "anyhow", "aquamarine", @@ -2353,8 +2354,8 @@ dependencies = [ [[package]] name = "fedimint-ln-common" -version = "0.4.2-rc.1" -source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.2-rc.1-fed-3#bc288b405705b3477f840cd2e5e03ff35174d084" +version = "0.4.3-rc.2" +source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.3-rc.2-fed4#eb40053b816c86192fbfd5e2a61f6d9c1670d789" dependencies = [ "anyhow", "bitcoin 0.30.2", @@ -2374,8 +2375,8 @@ dependencies = [ [[package]] name = "fedimint-ln-gateway" -version = "0.4.2-rc.1" -source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.2-rc.1-fed-3#bc288b405705b3477f840cd2e5e03ff35174d084" +version = "0.4.3-rc.2" +source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.3-rc.2-fed4#eb40053b816c86192fbfd5e2a61f6d9c1670d789" dependencies = [ "anyhow", "aquamarine", @@ -2426,8 +2427,8 @@ dependencies = [ [[package]] name = "fedimint-ln-server" -version = "0.4.2-rc.1" -source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.2-rc.1-fed-3#bc288b405705b3477f840cd2e5e03ff35174d084" +version = "0.4.3-rc.2" +source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.3-rc.2-fed4#eb40053b816c86192fbfd5e2a61f6d9c1670d789" dependencies = [ "anyhow", "async-trait", @@ -2452,8 +2453,8 @@ dependencies = [ [[package]] name = "fedimint-lnv2-client" -version = "0.4.2-rc.1" -source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.2-rc.1-fed-3#bc288b405705b3477f840cd2e5e03ff35174d084" +version = "0.4.3-rc.2" +source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.3-rc.2-fed4#eb40053b816c86192fbfd5e2a61f6d9c1670d789" dependencies = [ "anyhow", "aquamarine", @@ -2481,8 +2482,8 @@ dependencies = [ [[package]] name = "fedimint-lnv2-common" -version = "0.4.2-rc.1" -source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.2-rc.1-fed-3#bc288b405705b3477f840cd2e5e03ff35174d084" +version = "0.4.3-rc.2" +source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.3-rc.2-fed4#eb40053b816c86192fbfd5e2a61f6d9c1670d789" dependencies = [ "anyhow", "bitcoin 0.30.2", @@ -2500,8 +2501,8 @@ dependencies = [ [[package]] name = "fedimint-lnv2-server" -version = "0.4.2-rc.1" -source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.2-rc.1-fed-3#bc288b405705b3477f840cd2e5e03ff35174d084" +version = "0.4.3-rc.2" +source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.3-rc.2-fed4#eb40053b816c86192fbfd5e2a61f6d9c1670d789" dependencies = [ "anyhow", "async-trait", @@ -2522,8 +2523,8 @@ dependencies = [ [[package]] name = "fedimint-logging" -version = "0.4.2-rc.1" -source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.2-rc.1-fed-3#bc288b405705b3477f840cd2e5e03ff35174d084" +version = "0.4.3-rc.2" +source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.3-rc.2-fed4#eb40053b816c86192fbfd5e2a61f6d9c1670d789" dependencies = [ "anyhow", "console-subscriber", @@ -2535,8 +2536,8 @@ dependencies = [ [[package]] name = "fedimint-meta-client" -version = "0.4.2-rc.1" -source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.2-rc.1-fed-3#bc288b405705b3477f840cd2e5e03ff35174d084" +version = "0.4.3-rc.2" +source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.3-rc.2-fed4#eb40053b816c86192fbfd5e2a61f6d9c1670d789" dependencies = [ "anyhow", "async-trait", @@ -2557,8 +2558,8 @@ dependencies = [ [[package]] name = "fedimint-meta-common" -version = "0.4.2-rc.1" -source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.2-rc.1-fed-3#bc288b405705b3477f840cd2e5e03ff35174d084" +version = "0.4.3-rc.2" +source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.3-rc.2-fed4#eb40053b816c86192fbfd5e2a61f6d9c1670d789" dependencies = [ "anyhow", "fedimint-core", @@ -2570,8 +2571,8 @@ dependencies = [ [[package]] name = "fedimint-meta-server" -version = "0.4.2-rc.1" -source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.2-rc.1-fed-3#bc288b405705b3477f840cd2e5e03ff35174d084" +version = "0.4.3-rc.2" +source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.3-rc.2-fed4#eb40053b816c86192fbfd5e2a61f6d9c1670d789" dependencies = [ "anyhow", "async-trait", @@ -2589,8 +2590,8 @@ dependencies = [ [[package]] name = "fedimint-metrics" -version = "0.4.2-rc.1" -source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.2-rc.1-fed-3#bc288b405705b3477f840cd2e5e03ff35174d084" +version = "0.4.3-rc.2" +source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.3-rc.2-fed4#eb40053b816c86192fbfd5e2a61f6d9c1670d789" dependencies = [ "anyhow", "axum 0.7.5", @@ -2603,8 +2604,8 @@ dependencies = [ [[package]] name = "fedimint-mint-client" -version = "0.4.2-rc.1" -source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.2-rc.1-fed-3#bc288b405705b3477f840cd2e5e03ff35174d084" +version = "0.4.3-rc.2" +source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.3-rc.2-fed4#eb40053b816c86192fbfd5e2a61f6d9c1670d789" dependencies = [ "anyhow", "aquamarine", @@ -2642,8 +2643,8 @@ dependencies = [ [[package]] name = "fedimint-mint-common" -version = "0.4.2-rc.1" -source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.2-rc.1-fed-3#bc288b405705b3477f840cd2e5e03ff35174d084" +version = "0.4.3-rc.2" +source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.3-rc.2-fed4#eb40053b816c86192fbfd5e2a61f6d9c1670d789" dependencies = [ "anyhow", "bincode", @@ -2658,8 +2659,8 @@ dependencies = [ [[package]] name = "fedimint-mint-server" -version = "0.4.2-rc.1" -source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.2-rc.1-fed-3#bc288b405705b3477f840cd2e5e03ff35174d084" +version = "0.4.3-rc.2" +source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.3-rc.2-fed4#eb40053b816c86192fbfd5e2a61f6d9c1670d789" dependencies = [ "anyhow", "async-trait", @@ -2684,8 +2685,8 @@ dependencies = [ [[package]] name = "fedimint-portalloc" -version = "0.4.2-rc.1" -source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.2-rc.1-fed-3#bc288b405705b3477f840cd2e5e03ff35174d084" +version = "0.4.3-rc.2" +source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.3-rc.2-fed4#eb40053b816c86192fbfd5e2a61f6d9c1670d789" dependencies = [ "anyhow", "dirs", @@ -2699,8 +2700,8 @@ dependencies = [ [[package]] name = "fedimint-rocksdb" -version = "0.4.2-rc.1" -source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.2-rc.1-fed-3#bc288b405705b3477f840cd2e5e03ff35174d084" +version = "0.4.3-rc.2" +source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.3-rc.2-fed4#eb40053b816c86192fbfd5e2a61f6d9c1670d789" dependencies = [ "anyhow", "async-trait", @@ -2713,8 +2714,8 @@ dependencies = [ [[package]] name = "fedimint-server" -version = "0.4.2-rc.1" -source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.2-rc.1-fed-3#bc288b405705b3477f840cd2e5e03ff35174d084" +version = "0.4.3-rc.2" +source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.3-rc.2-fed4#eb40053b816c86192fbfd5e2a61f6d9c1670d789" dependencies = [ "aleph-bft-types 0.13.0", "anyhow", @@ -2766,8 +2767,8 @@ dependencies = [ [[package]] name = "fedimint-tbs" -version = "0.4.2-rc.1" -source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.2-rc.1-fed-3#bc288b405705b3477f840cd2e5e03ff35174d084" +version = "0.4.3-rc.2" +source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.3-rc.2-fed4#eb40053b816c86192fbfd5e2a61f6d9c1670d789" dependencies = [ "bls12_381", "fedimint-core", @@ -2781,8 +2782,8 @@ dependencies = [ [[package]] name = "fedimint-testing" -version = "0.4.2-rc.1" -source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.2-rc.1-fed-3#bc288b405705b3477f840cd2e5e03ff35174d084" +version = "0.4.3-rc.2" +source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.3-rc.2-fed4#eb40053b816c86192fbfd5e2a61f6d9c1670d789" dependencies = [ "anyhow", "async-stream", @@ -2859,8 +2860,8 @@ dependencies = [ [[package]] name = "fedimint-tpe" -version = "0.4.2-rc.1" -source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.2-rc.1-fed-3#bc288b405705b3477f840cd2e5e03ff35174d084" +version = "0.4.3-rc.2" +source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.3-rc.2-fed4#eb40053b816c86192fbfd5e2a61f6d9c1670d789" dependencies = [ "bitcoin_hashes 0.12.0", "bls12_381", @@ -2874,8 +2875,8 @@ dependencies = [ [[package]] name = "fedimint-unknown-common" -version = "0.4.2-rc.1" -source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.2-rc.1-fed-3#bc288b405705b3477f840cd2e5e03ff35174d084" +version = "0.4.3-rc.2" +source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.3-rc.2-fed4#eb40053b816c86192fbfd5e2a61f6d9c1670d789" dependencies = [ "anyhow", "fedimint-core", @@ -2885,8 +2886,8 @@ dependencies = [ [[package]] name = "fedimint-unknown-server" -version = "0.4.2-rc.1" -source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.2-rc.1-fed-3#bc288b405705b3477f840cd2e5e03ff35174d084" +version = "0.4.3-rc.2" +source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.3-rc.2-fed4#eb40053b816c86192fbfd5e2a61f6d9c1670d789" dependencies = [ "anyhow", "async-trait", @@ -2899,8 +2900,8 @@ dependencies = [ [[package]] name = "fedimint-wallet-client" -version = "0.4.2-rc.1" -source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.2-rc.1-fed-3#bc288b405705b3477f840cd2e5e03ff35174d084" +version = "0.4.3-rc.2" +source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.3-rc.2-fed4#eb40053b816c86192fbfd5e2a61f6d9c1670d789" dependencies = [ "anyhow", "aquamarine", @@ -2928,8 +2929,8 @@ dependencies = [ [[package]] name = "fedimint-wallet-common" -version = "0.4.2-rc.1" -source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.2-rc.1-fed-3#bc288b405705b3477f840cd2e5e03ff35174d084" +version = "0.4.3-rc.2" +source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.3-rc.2-fed4#eb40053b816c86192fbfd5e2a61f6d9c1670d789" dependencies = [ "anyhow", "bitcoin 0.30.2", @@ -2945,8 +2946,8 @@ dependencies = [ [[package]] name = "fedimint-wallet-server" -version = "0.4.2-rc.1" -source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.2-rc.1-fed-3#bc288b405705b3477f840cd2e5e03ff35174d084" +version = "0.4.3-rc.2" +source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.3-rc.2-fed4#eb40053b816c86192fbfd5e2a61f6d9c1670d789" dependencies = [ "anyhow", "async-trait", @@ -2973,8 +2974,8 @@ dependencies = [ [[package]] name = "fedimintd" -version = "0.4.2-rc.1" -source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.2-rc.1-fed-3#bc288b405705b3477f840cd2e5e03ff35174d084" +version = "0.4.3-rc.2" +source = "git+https://github.com/fedibtc/fedimint?tag=v0.4.3-rc.2-fed4#eb40053b816c86192fbfd5e2a61f6d9c1670d789" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 0f44f25..fa2264e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -96,25 +96,25 @@ tracing-subscriber = { version = "0.3.16", features = ["env-filter"] } fedimint_threshold_crypto = { version = "0.2.1", package = "fedimint-threshold-crypto" } -fedimintd = { git = "https://github.com/fedibtc/fedimint", tag = "v0.4.2-rc.1-fed-3" } -fedimint-cli = { git = "https://github.com/fedibtc/fedimint", tag = "v0.4.2-rc.1-fed-3" } -fedimint-build = { git = "https://github.com/fedibtc/fedimint", tag = "v0.4.2-rc.1-fed-3" } -fedimint-core = { git = "https://github.com/fedibtc/fedimint", tag = "v0.4.2-rc.1-fed-3" } -fedimint-server = { git = "https://github.com/fedibtc/fedimint", tag = "v0.4.2-rc.1-fed-3" } -fedimint-client = { git = "https://github.com/fedibtc/fedimint", tag = "v0.4.2-rc.1-fed-3" } -fedimint-api-client = { git = "https://github.com/fedibtc/fedimint", tag = "v0.4.2-rc.1-fed-3" } -fedimint-derive-secret = { git = "https://github.com/fedibtc/fedimint", tag = "v0.4.2-rc.1-fed-3" } -fedimint-mint-client = { git = "https://github.com/fedibtc/fedimint", tag = "v0.4.2-rc.1-fed-3" } -fedimint-wallet-client = { git = "https://github.com/fedibtc/fedimint", tag = "v0.4.2-rc.1-fed-3" } -fedimint-ln-client = { git = "https://github.com/fedibtc/fedimint", tag = "v0.4.2-rc.1-fed-3" } -fedimint-ln-common = { git = "https://github.com/fedibtc/fedimint", tag = "v0.4.2-rc.1-fed-3" } -fedimint-client-legacy = { git = "https://github.com/fedibtc/fedimint", tag = "v0.4.2-rc.1-fed-3" } -fedimint-aead = { git = "https://github.com/fedibtc/fedimint", tag = "v0.4.2-rc.1-fed-3" } -fedimint-rocksdb = { git = "https://github.com/fedibtc/fedimint", tag = "v0.4.2-rc.1-fed-3" } -fedimint-bip39 = { git = "https://github.com/fedibtc/fedimint", tag = "v0.4.2-rc.1-fed-3" } -fedimint-logging = { git = "https://github.com/fedibtc/fedimint", tag = "v0.4.2-rc.1-fed-3" } -devimint = { git = "https://github.com/fedibtc/fedimint", tag = "v0.4.2-rc.1-fed-3" } -ln-gateway = { git = "https://github.com/fedibtc/fedimint", tag = "v0.4.2-rc.1-fed-3" } +fedimintd = { git = "https://github.com/fedibtc/fedimint", tag = "v0.4.3-rc.2-fed4" } +fedimint-cli = { git = "https://github.com/fedibtc/fedimint", tag = "v0.4.3-rc.2-fed4" } +fedimint-build = { git = "https://github.com/fedibtc/fedimint", tag = "v0.4.3-rc.2-fed4" } +fedimint-core = { git = "https://github.com/fedibtc/fedimint", tag = "v0.4.3-rc.2-fed4" } +fedimint-server = { git = "https://github.com/fedibtc/fedimint", tag = "v0.4.3-rc.2-fed4" } +fedimint-client = { git = "https://github.com/fedibtc/fedimint", tag = "v0.4.3-rc.2-fed4" } +fedimint-api-client = { git = "https://github.com/fedibtc/fedimint", tag = "v0.4.3-rc.2-fed4" } +fedimint-derive-secret = { git = "https://github.com/fedibtc/fedimint", tag = "v0.4.3-rc.2-fed4" } +fedimint-mint-client = { git = "https://github.com/fedibtc/fedimint", tag = "v0.4.3-rc.2-fed4" } +fedimint-wallet-client = { git = "https://github.com/fedibtc/fedimint", tag = "v0.4.3-rc.2-fed4" } +fedimint-ln-client = { git = "https://github.com/fedibtc/fedimint", tag = "v0.4.3-rc.2-fed4" } +fedimint-ln-common = { git = "https://github.com/fedibtc/fedimint", tag = "v0.4.3-rc.2-fed4" } +fedimint-meta-client = { git = "https://github.com/fedibtc/fedimint", tag = "v0.4.3-rc.2-fed4" } +fedimint-aead = { git = "https://github.com/fedibtc/fedimint", tag = "v0.4.3-rc.2-fed4" } +fedimint-rocksdb = { git = "https://github.com/fedibtc/fedimint", tag = "v0.4.3-rc.2-fed4" } +fedimint-bip39 = { git = "https://github.com/fedibtc/fedimint", tag = "v0.4.3-rc.2-fed4" } +fedimint-logging = { git = "https://github.com/fedibtc/fedimint", tag = "v0.4.3-rc.2-fed4" } +devimint = { git = "https://github.com/fedibtc/fedimint", tag = "v0.4.3-rc.2-fed4" } +ln-gateway = { git = "https://github.com/fedibtc/fedimint", tag = "v0.4.3-rc.2-fed4" } # Uncomment these to use local fedimint v2 diff --git a/SECURITY.md b/SECURITY.md index d757f3f..54162d0 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,6 +4,7 @@ | Version | Supported | | ------- | ------------------ | +| 1.20.1+ | :white_check_mark: | | 1.19.0+ | :white_check_mark: | | 1.18.0+ | :white_check_mark: | | < 1.17 | :x: | diff --git a/bridge/fedi-ffi/Cargo.toml b/bridge/fedi-ffi/Cargo.toml index 847c4bd..a5b1e0f 100644 --- a/bridge/fedi-ffi/Cargo.toml +++ b/bridge/fedi-ffi/Cargo.toml @@ -17,6 +17,7 @@ fedimint-ln-client = { workspace = true } fedimint-api-client = { workspace = true } fedimint-ln-common = { workspace = true } fedimint-wallet-client = { workspace = true } +fedimint-meta-client = { workspace = true } fedi-social-client = { workspace = true } stability-pool-client = { path = "../../modules/stability-pool/client" } fedi-db-dump = { path = "../../fedi-db-dump" } diff --git a/bridge/fedi-ffi/src/bridge.rs b/bridge/fedi-ffi/src/bridge.rs index 8e40bc9..cfda258 100644 --- a/bridge/fedi-ffi/src/bridge.rs +++ b/bridge/fedi-ffi/src/bridge.rs @@ -1,3 +1,4 @@ +use std::collections::btree_map::Entry; use std::collections::BTreeMap; use std::path::PathBuf; use std::str::FromStr; @@ -24,7 +25,7 @@ use fedimint_core::PeerId; use fedimint_derive_secret::{ChildId, DerivableSecret}; use futures::future::join_all; use tokio::sync::{Mutex, OnceCell}; -use tracing::{debug, error, info, info_span, warn, Instrument}; +use tracing::{debug, error, info, warn}; use super::event::EventSink; use super::storage::Storage; @@ -43,11 +44,11 @@ use crate::federation_v2::{self, FederationV2}; use crate::fedi_fee::FediFeeHelper; use crate::matrix::Matrix; use crate::storage::{ - AppState, DatabaseInfo, FederationInfo, FediFeeSchedule, ModuleFediFeeSchedule, + AppState, DatabaseInfo, FederationInfo, FediFeeSchedule, FiatFXInfo, ModuleFediFeeSchedule, }; use crate::types::{ - RpcBridgeStatus, RpcDeviceIndexAssignmentStatus, RpcFederationPreview, RpcNostrPubkey, - RpcNostrSecret, RpcRegisteredDevice, RpcReturningMemberStatus, + RpcBridgeStatus, RpcDeviceIndexAssignmentStatus, RpcFederationMaybeLoading, + RpcFederationPreview, RpcNostrPubkey, RpcNostrSecret, RpcRegisteredDevice, }; use crate::utils::required_threashold_of; @@ -55,6 +56,13 @@ use crate::utils::required_threashold_of; pub const RECOVERY_FILENAME: &str = "backup.fedi"; pub const VERIFICATION_FILENAME: &str = "verification.mp4"; +#[derive(Clone)] +pub enum FederationMaybeLoading { + Loading, + Ready(Arc), + Failed(Arc), +} + /// This is instantiated once as a global. When RPC commands come in, this /// struct is used as a router to look up the federation and handle the RPC /// command using it. @@ -62,7 +70,7 @@ pub const VERIFICATION_FILENAME: &str = "verification.mp4"; pub struct Bridge { pub storage: Storage, pub app_state: Arc, - pub federations: Arc>>>, + pub federations: Arc>>, pub communities: Arc, pub event_sink: EventSink, pub task_group: TaskGroup, @@ -104,79 +112,8 @@ impl Bridge { ) .into(); - let root_mnemonic = app_state.root_mnemonic().await; - - let device_index = app_state.device_index().await; - - // load joined federations - let joined_federations = app_state - .with_read_lock(|state| state.joined_federations.clone()) - .await - .into_iter() - .collect::>(); - let global_db = storage.federation_database_v2("global").await?; - let federations = joined_federations - .iter() - // Ignore older version - .filter(|(_, info)| info.version >= 2) - .map(|(federation_id_str, federation_info)| { - async { - Ok::<(String, Arc), anyhow::Error>(( - federation_id_str.clone(), - match federation_info.version { - 2 => { - let db = match &federation_info.database { - DatabaseInfo::DatabaseName(db_name) => { - storage.federation_database_v2(db_name).await? - } - DatabaseInfo::DatabasePrefix(prefix) => { - // use varint encoding so most of prefixes serialize to - // single byte - global_db.with_prefix(prefix.consensus_encode_to_vec()) - } - }; - Arc::new( - FederationV2::from_db( - db, - event_sink.clone(), - task_group.make_subgroup(), - &root_mnemonic, - // Always present when join federations exist - device_index.context( - "device index must exist when joined federations exist", - )?, - fedi_fee_helper.clone(), - feature_catalog.clone(), - ) - .await - .with_context(|| { - format!("loading federation {}", federation_id_str.clone()) - })?, - ) - } - n => bail!("Invalid federation version {n}"), - }, - )) - } - .instrument(info_span!("federation", federation_id = federation_id_str)) - }); - - let federations = Arc::new(Mutex::new( - futures::future::join_all(federations) - .await - .into_iter() - .filter_map(|federation_res| match federation_res { - Ok((id, federation)) => Some((id, federation)), - Err(e) => { - error!("Could not initialize federation client: {e:?}"); - None - } - }) - .collect::>(), - )); - // Load communities module let communities = Communities::init( app_state.clone(), @@ -186,23 +123,10 @@ impl Bridge { .await .into(); - // Spawn a new task to asynchronously fetch the fee schedule and update app - // state - fedi_fee_helper - .fetch_and_update_fedi_fee_schedule( - federations - .lock() - .await - .iter() - .filter_map(|(id, fed)| Some((id.clone(), fed.get_network()?))) - .collect(), - ) - .await; - let bridge = Self { storage, app_state, - federations, + federations: Arc::default(), communities, event_sink, task_group, @@ -213,13 +137,139 @@ impl Bridge { global_db, feature_catalog, }; - let federations = bridge.federations.lock().await.clone(); - for federation in federations.into_values() { - Self::restart_federation_on_recovery(bridge.clone(), federation).await; - } + Self::load_joined_federations_in_background(bridge.clone()).await?; Ok(bridge) } + async fn load_joined_federations_in_background(bridge: Bridge) -> Result<()> { + let joined_federations = bridge + .app_state + .with_read_lock(|state| state.joined_federations.clone()) + .await; + + let mut futures = Vec::new(); + let mut federations = bridge.federations.lock().await; + for (federation_id, federation_info) in joined_federations { + if federation_info.version < 2 { + error!(version = federation_info.version, %federation_id, "Invalid federation version"); + continue; + } + federations.insert(federation_id.clone(), FederationMaybeLoading::Loading); + + futures.push(Bridge::load_federation( + bridge.clone(), + federation_id.clone(), + federation_info, + )); + } + drop(federations); + + // FIXME: update after each federation is loaded. + bridge.task_group.clone().spawn_cancellable( + "load federation and update fedi fee schedule", + async move { + futures::future::join_all(futures).await; + bridge.update_fedi_fees_schedule().await; + }, + ); + + Ok(()) + } + + async fn update_fedi_fees_schedule(&self) { + // Spawn a new task to asynchronously fetch the fee schedule and update app + // state + let fed_network_map = self + .federations + .lock() + .await + .iter() + .filter_map(|(id, fed)| match fed { + FederationMaybeLoading::Ready(fed) => Some((id.clone(), fed.get_network()?)), + _ => None, + }) + .collect(); + + self.fedi_fee_helper + .fetch_and_update_fedi_fee_schedule(fed_network_map) + .await; + } + + #[tracing::instrument(skip_all, err, fields(federation_id = federation_id_str))] + async fn load_federation( + bridge: Bridge, + federation_id_str: String, + federation_info: FederationInfo, + ) -> Result<()> { + let root_mnemonic = bridge.app_state.root_mnemonic().await; + let device_index = bridge + .app_state + .device_index() + .await + .context("device index must exist when joined federations exist")?; + + let db = match &federation_info.database { + DatabaseInfo::DatabaseName(db_name) => { + bridge.storage.federation_database_v2(db_name).await? + } + DatabaseInfo::DatabasePrefix(prefix) => bridge + .global_db + .with_prefix(prefix.consensus_encode_to_vec()), + }; + + let federation_result = FederationV2::from_db( + db, + bridge.event_sink.clone(), + bridge.task_group.make_subgroup(), + &root_mnemonic, + device_index, + bridge.fedi_fee_helper.clone(), + bridge.feature_catalog.clone(), + bridge.app_state.clone(), + ) + .await; + + match federation_result { + Ok(federation) => { + let federation_arc = Arc::new(federation); + bridge.federations.lock().await.insert( + federation_id_str.clone(), + FederationMaybeLoading::Ready(federation_arc.clone()), + ); + + bridge + .send_federation_event(RpcFederationMaybeLoading::Ready( + federation_v2_to_rpc_federation(&federation_arc).await, + )) + .await; + if federation_arc.recovering() { + Self::restart_federation_on_recovery(bridge.clone(), federation_arc).await; + } + } + Err(err) => { + error!(%err, "federation failed to load"); + bridge + .send_federation_event(RpcFederationMaybeLoading::Failed { + error: err.to_string(), + id: RpcFederationId(federation_id_str.clone()), + }) + .await; + bridge.federations.lock().await.insert( + federation_id_str.clone(), + FederationMaybeLoading::Failed(Arc::new(err)), + ); + } + } + + Ok(()) + } + + /// Send whenever federation is loaded. + pub async fn send_federation_event(&self, rpc_federation: RpcFederationMaybeLoading) { + let event = Event::federation(rpc_federation); + self.event_sink.typed_event(&event); + } + pub async fn bridge_status(&self) -> anyhow::Result { let matrix_setup = self .app_state @@ -268,10 +318,7 @@ impl Bridge { } /// Restart the federation on recovery if the federation is recovering. - pub async fn restart_federation_on_recovery(this: Self, federation: Arc) { - if !federation.recovering() { - return; - } + async fn restart_federation_on_recovery(this: Self, federation: Arc) { this.task_group.clone().spawn( "waiting for recovery to replace federation", move |_| async move { @@ -286,7 +333,9 @@ impl Bridge { let client = inner_federation.client.clone(); let mut federation_lock = this.federations.lock().await; let db = client.db().clone(); - drop(federation_lock.remove(&federation_id)); + drop( + federation_lock.insert(federation_id.clone(), FederationMaybeLoading::Loading), + ); info!(%federation_id, "removed from federation list"); if let Err(error) = tg.shutdown_join_all(None).await { @@ -323,6 +372,7 @@ impl Bridge { device_index, this.fedi_fee_helper.clone(), this.feature_catalog.clone(), + this.app_state.clone(), ) .await .with_context(|| format!("loading federation {}", federation_id.clone()))?; @@ -330,7 +380,7 @@ impl Bridge { if federation_v2.recovering() { error!(%federation_id, "federation must be recovered after restart on recovery completed once"); } - federation_lock.insert(federation_id.clone(), Arc::new(federation_v2)); + federation_lock.insert(federation_id.clone(), FederationMaybeLoading::Ready(Arc::new(federation_v2))); info!(%federation_id, "reinserted to federation list"); drop(federation_lock); @@ -372,6 +422,19 @@ impl Bridge { Err(e) => { error!("failed to join v2 federation {e:?}"); error_code = error_code.or(get_error_code(&e)); + + // If error code is NOT "AlreadyJoined" AND + // If federation is still under loading state, + // then let's remove it so that another attempt can happen later + if !matches!(error_code, Some(ErrorCode::AlreadyJoined)) { + let invite_code = InviteCode::from_str(&invite_code_string)?; + let mut federations = self.federations.lock().await; + if let Some(FederationMaybeLoading::Loading) = + federations.get(&invite_code.federation_id().to_string()) + { + federations.remove(&invite_code.federation_id().to_string()); + } + } } } if let Some(error_code) = error_code { @@ -385,15 +448,16 @@ impl Bridge { invite_code_string: String, recover_from_scratch: bool, ) -> Result> { - // Check if we've already joined this federation + // Check if we've already joined this federation. If we have throw error, + // otherwise write loading state let invite_code = InviteCode::from_str(&invite_code_string)?; - if self - .get_federation_maybe_recovering(&invite_code.federation_id().to_string()) - .await - .is_ok() - { + let mut federations = self.federations.lock().await; + if let Entry::Vacant(e) = federations.entry(invite_code.federation_id().to_string()) { + e.insert(FederationMaybeLoading::Loading); + } else { bail!(ErrorCode::AlreadyJoined) } + drop(federations); let root_mnemonic = self.app_state.root_mnemonic().await; let device_index = self.app_state.ensure_device_index().await?; @@ -416,6 +480,7 @@ impl Bridge { recover_from_scratch, self.fedi_fee_helper.clone(), self.feature_catalog.clone(), + self.app_state.clone(), ) .await?; let federation_id = federation.federation_id(); @@ -426,7 +491,7 @@ impl Bridge { // DB file is random so there shouldn't be any collisions. self.app_state .with_write_lock(|state| { - state.joined_federations.insert( + let old_value = state.joined_federations.insert( federation_id.to_string(), FederationInfo { version: 2, @@ -434,24 +499,22 @@ impl Bridge { fedi_fee_schedule: FediFeeSchedule::default(), }, ); + assert!(old_value.is_none(), "must not override a federation"); }) .await?; let federation_arc = Arc::new(federation); - federations - .entry(federation_id.to_string()) - .or_insert_with(|| federation_arc.clone()); - Self::restart_federation_on_recovery(self.clone(), federation_arc.clone()).await; + federations.insert( + federation_id.to_string(), + FederationMaybeLoading::Ready(federation_arc.clone()), + ); + drop(federations); + if federation_arc.recovering() { + Self::restart_federation_on_recovery(self.clone(), federation_arc.clone()).await; + } // Spawn a new task to asynchronously fetch the fee schedule and update app // state - self.fedi_fee_helper - .fetch_and_update_fedi_fee_schedule( - federations - .iter() - .filter_map(|(id, fed)| Some((id.clone(), fed.get_network()?))) - .collect(), - ) - .await; + self.update_fedi_fees_schedule().await; Ok(federation_arc) } @@ -460,30 +523,13 @@ impl Bridge { let invite_code = invite_code.to_lowercase(); let root_mnemonic = self.app_state.root_mnemonic().await; let device_index = self.app_state.ensure_device_index().await?; - let (config, backup) = FederationV2::download_client_config( + FederationV2::federation_preview( &invite_code, &root_mnemonic, device_index, self.feature_catalog.override_localhost.is_some(), ) .await - .context("failed to connect")?; - Ok(RpcFederationPreview { - id: RpcFederationId(config.global.calculate_federation_id().to_string()), - name: config - .global - .federation_name() - .map(|x| x.to_owned()) - .unwrap_or(config.global.calculate_federation_id().to_string()[0..8].to_string()), - meta: config.global.meta, - invite_code: invite_code.to_string(), - version: 2, - returning_member_status: match backup { - Ok(Some(_)) => RpcReturningMemberStatus::ReturningMember, - Ok(None) => RpcReturningMemberStatus::NewMember, - Err(_) => RpcReturningMemberStatus::Unknown, - }, - }) } /// Look up federation by id from in-memory hashmap @@ -498,22 +544,44 @@ impl Bridge { &self, federation_id: &str, ) -> Result> { + match self.get_federation_maybe_loading(federation_id).await? { + FederationMaybeLoading::Ready(federation) => Ok(federation), + FederationMaybeLoading::Loading => bail!("Federation is still loading"), + FederationMaybeLoading::Failed(e) => bail!("Federation failed to load: {}", e), + } + } + /// Look up federation by id from in-memory hashmap + async fn get_federation_maybe_loading( + &self, + federation_id: &str, + ) -> Result { let lock = self.federations.lock().await; lock.get(federation_id) .cloned() .ok_or_else(|| anyhow!("Federation not found")) } - pub async fn list_federations(&self) -> Vec { + pub async fn list_federations(&self) -> Vec { let federations = self.federations.lock().await.clone(); - join_all(federations.into_values().map(|federation| async move { - federation_v2_to_rpc_federation(&federation.clone()).await + join_all(federations.into_iter().map(|(id, federation)| async move { + match federation { + FederationMaybeLoading::Ready(fed) => { + RpcFederationMaybeLoading::Ready(federation_v2_to_rpc_federation(&fed).await) + } + FederationMaybeLoading::Loading => RpcFederationMaybeLoading::Loading { + id: RpcFederationId(id), + }, + FederationMaybeLoading::Failed(err) => RpcFederationMaybeLoading::Failed { + error: err.to_string(), + id: RpcFederationId(id), + }, + } })) .await } pub async fn leave_federation(&self, federation_id_str: &str) -> Result<()> { - // check if federation exists and not recovering + // check if federation is loaded and not recovering self.get_federation(federation_id_str).await?; // delete federation from app state (global DB) let federation_id = federation_id_str.to_owned(); @@ -539,24 +607,26 @@ impl Bridge { bail!("federation must be present in state"); }; - if let Err(error) = federation - .task_group - .clone() - .shutdown_join_all(Some(Duration::from_secs(20))) - .await - { - warn!(%error, "failed to shutdown task group cleanly"); - } + if let FederationMaybeLoading::Ready(federation) = federation { + if let Err(error) = federation + .task_group + .clone() + .shutdown_join_all(Some(Duration::from_secs(20))) + .await + { + warn!(%error, "failed to shutdown task group cleanly"); + } - if fedimint_core::task::timeout( - Duration::from_secs(20), - Self::wait_till_fully_dropped(federation), - ) - .await - .is_err() - { - info!("failed to drop federation, not deleting the database"); - return Ok(()); + if fedimint_core::task::timeout( + Duration::from_secs(20), + Self::wait_till_fully_dropped(federation), + ) + .await + .is_err() + { + info!("failed to drop federation, not deleting the database"); + return Ok(()); + } } match removed_federation_info.database { @@ -603,6 +673,12 @@ impl Bridge { .collect()) } + pub async fn update_cached_fiat_fx_info(&self, info: FiatFXInfo) -> anyhow::Result<()> { + self.app_state + .with_write_lock(|state| state.cached_fiat_fx_info = Some(info)) + .await + } + /// Enable logging of potentially sensitive information. pub async fn sensitive_log(&self) -> bool { self.app_state diff --git a/bridge/fedi-ffi/src/envs.rs b/bridge/fedi-ffi/src/envs.rs new file mode 100644 index 0000000..985c212 --- /dev/null +++ b/bridge/fedi-ffi/src/envs.rs @@ -0,0 +1,4 @@ +// Env variable to use upstream fedimintd binary which represents a stock +// federation without custom Fedi modules like stability pool or social +// recovery. +pub const USE_UPSTREAM_FEDIMINTD_ENV: &str = "USE_UPSTREAM_FEDIMINTD"; diff --git a/bridge/fedi-ffi/src/error.rs b/bridge/fedi-ffi/src/error.rs index a71ae0d..2d91317 100644 --- a/bridge/fedi-ffi/src/error.rs +++ b/bridge/fedi-ffi/src/error.rs @@ -43,6 +43,8 @@ pub enum ErrorCode { PayLnInvoiceAlreadyInProgress, #[error("No Lightning gateway is available")] NoLnGatewayAvailable, + #[error("Module of type {0} is not available")] + ModuleNotFound(String), } pub fn get_error_code(err: &anyhow::Error) -> Option { diff --git a/bridge/fedi-ffi/src/event.rs b/bridge/fedi-ffi/src/event.rs index 821e6d0..b05689c 100644 --- a/bridge/fedi-ffi/src/event.rs +++ b/bridge/fedi-ffi/src/event.rs @@ -4,11 +4,9 @@ use fedimint_core::task::{MaybeSend, MaybeSync}; use serde::{Deserialize, Serialize}; use ts_rs::TS; -use super::types::{ - RpcFederation, RpcFederationId, RpcOperationId, RpcTransaction, SocialRecoveryApproval, -}; +use super::types::{RpcFederationId, RpcOperationId, RpcTransaction, SocialRecoveryApproval}; use crate::observable::ObservableUpdate; -use crate::types::{RpcAmount, RpcCommunity}; +use crate::types::{RpcAmount, RpcCommunity, RpcFederationMaybeLoading}; #[derive(Serialize, Deserialize, Debug, TS)] #[serde(rename_all = "camelCase")] @@ -184,7 +182,7 @@ pub struct CommunityMetadataUpdatedEvent { pub enum Event { Transaction(Box), Log(LogEvent), - Federation(RpcFederation), + Federation(RpcFederationMaybeLoading), Balance(BalanceEvent), Panic(PanicEvent), StabilityPoolDeposit(StabilityPoolDepositEvent), @@ -206,7 +204,7 @@ impl Event { pub fn log(log: String) -> Self { Self::Log(LogEvent { log }) } - pub fn federation(federation: RpcFederation) -> Self { + pub fn federation(federation: RpcFederationMaybeLoading) -> Self { Self::Federation(federation) } pub fn balance(federation_id: String, balance: fedimint_core::Amount) -> Self { diff --git a/bridge/fedi-ffi/src/federation_v2/backup_service.rs b/bridge/fedi-ffi/src/federation_v2/backup_service.rs index 1b24752..f9f9e49 100644 --- a/bridge/fedi-ffi/src/federation_v2/backup_service.rs +++ b/bridge/fedi-ffi/src/federation_v2/backup_service.rs @@ -14,7 +14,7 @@ use ts_rs::TS; use super::super::constants::BACKUP_FREQUENCY; use super::super::types::FediBackupMetadata; -use super::db::{LastBackupTimestampKey, XmppUsernameKey}; +use super::db::LastBackupTimestampKey; use crate::utils::to_unix_time; #[derive(Default)] @@ -74,13 +74,7 @@ impl BackupService { } async fn backup_inner(&self, client: &Client) -> Result<()> { - let username = client - .db() - .begin_transaction_nc() - .await - .get_value(&XmppUsernameKey) - .await; - let backup = FediBackupMetadata::new(username); + let backup = FediBackupMetadata::new(); client .backup_to_federation(Metadata::from_json_serialized(backup)) .await?; diff --git a/bridge/fedi-ffi/src/federation_v2/client.rs b/bridge/fedi-ffi/src/federation_v2/client.rs new file mode 100644 index 0000000..6c7903c --- /dev/null +++ b/bridge/fedi-ffi/src/federation_v2/client.rs @@ -0,0 +1,66 @@ +use anyhow::anyhow; +use fedimint_client::module::ClientModule; +use fedimint_client::{Client, ClientModuleInstance}; +use fedimint_ln_client::LightningClientModule; +use fedimint_mint_client::MintClientModule; +use fedimint_wallet_client::WalletClientModule; +use stability_pool_client::StabilityPoolClientModule; + +use crate::error::ErrorCode; + +/// Helper functions for fedimint_client::Client +pub trait ClientExt { + /// Attempt to get the first module of the specified kind without panicking. + /// Returns error if the module is not found. + fn try_get_first_module(&self) -> anyhow::Result>; + + /// Attempt to get the first lightning client module instance. + fn ln(&self) -> anyhow::Result>; + + /// Attempt to get the first wallet client module instance. + fn wallet(&self) -> anyhow::Result>; + + /// Attempt to get the first stability pool client module instance. + fn sp(&self) -> anyhow::Result>; + + /// Attempt to get the first mint (e-cash) client module instance. + fn mint(&self) -> anyhow::Result>; +} + +impl ClientExt for Client { + // Copied from fedimint-client + // TODO: check during fedimint upgrade and remove if unnecessary + fn try_get_first_module(&self) -> anyhow::Result> { + let module_kind = M::kind(); + let id = self + .get_first_instance(&module_kind) + .ok_or(anyhow!(ErrorCode::ModuleNotFound(module_kind.to_string())))?; + self.get_module_client_dyn(id) + .map_err(|_| anyhow!(ErrorCode::ModuleNotFound(module_kind.to_string())))? + .as_any() + .downcast_ref::() + .ok_or(anyhow!(ErrorCode::ModuleNotFound(module_kind.to_string())))?; + + // We cannot construct an instance of ClientModuleInstance ourselves since the + // module field is private. However, at this point, we've verified that + // the module exists. So calling Client::get_first_module should + // be successful. + Ok(self.get_first_module::()) + } + + fn ln(&self) -> anyhow::Result> { + self.try_get_first_module::() + } + + fn wallet(&self) -> anyhow::Result> { + self.try_get_first_module::() + } + + fn sp(&self) -> anyhow::Result> { + self.try_get_first_module::() + } + + fn mint(&self) -> anyhow::Result> { + self.try_get_first_module::() + } +} diff --git a/bridge/fedi-ffi/src/federation_v2/db.rs b/bridge/fedi-ffi/src/federation_v2/db.rs index 1051f60..7d0c0a9 100644 --- a/bridge/fedi-ffi/src/federation_v2/db.rs +++ b/bridge/fedi-ffi/src/federation_v2/db.rs @@ -5,7 +5,7 @@ use fedimint_core::core::{ModuleKind, OperationId}; use fedimint_core::encoding::{Decodable, Encodable}; use fedimint_core::{impl_db_lookup, impl_db_record, Amount}; -use crate::types::{OperationFediFeeStatus, RpcTransactionDirection}; +use crate::types::{OperationFediFeeStatus, RpcTransactionDirection, TransactionDateFiatInfo}; #[repr(u8)] pub enum BridgeDbPrefix { @@ -15,6 +15,7 @@ pub enum BridgeDbPrefix { #[allow(dead_code)] FedimintUserData = 0xb0, ClientConfig = 0xb1, + #[deprecated] XmppUsername = 0xb2, InviteCode = 0xb3, LastBackupTimestamp = 0xb4, @@ -43,6 +44,11 @@ pub enum BridgeDbPrefix { OutstandingFediFeesPerTXType = 0xbd, PendingFediFeesPerTXType = 0xbe, + // For each TX, we record the fiat display currency and the fiat value at the time of the TX. + // This is so that we can display historical values in the TX list as opposed to constantly + // updating live fiat values. + TransactionDateFiatInfo = 0xbf, + // Do not use anything after this key (inclusive) // see https://github.com/fedimint/fedimint/pull/4445 #[allow(dead_code)] @@ -58,15 +64,6 @@ impl_db_record!( db_prefix = BridgeDbPrefix::ClientConfig, ); -#[derive(Debug, Decodable, Encodable)] -pub struct XmppUsernameKey; - -impl_db_record!( - key = XmppUsernameKey, - value = String, - db_prefix = BridgeDbPrefix::XmppUsername, -); - #[derive(Debug, Decodable, Encodable)] pub struct InviteCodeKey; @@ -172,3 +169,12 @@ impl_db_lookup!( key = PendingFediFeesPerTXTypeKey, query_prefix = PendingFediFeesPerTXTypeKeyPrefix, ); + +#[derive(Debug, Decodable, Encodable)] +pub struct TransactionDateFiatInfoKey(pub OperationId); + +impl_db_record!( + key = TransactionDateFiatInfoKey, + value = TransactionDateFiatInfo, + db_prefix = BridgeDbPrefix::TransactionDateFiatInfo, +); diff --git a/bridge/fedi-ffi/src/federation_v2/ln_gateway_service.rs b/bridge/fedi-ffi/src/federation_v2/ln_gateway_service.rs index f304ca1..39d3f9d 100644 --- a/bridge/fedi-ffi/src/federation_v2/ln_gateway_service.rs +++ b/bridge/fedi-ffi/src/federation_v2/ln_gateway_service.rs @@ -4,43 +4,33 @@ use std::sync::Arc; use std::time::Duration; use bitcoin::secp256k1::{self, PublicKey}; -use fedimint_client::{Client, ClientHandle, ClientModuleInstance}; +use fedimint_client::{Client, ClientHandle}; use fedimint_core::config::META_VETTED_GATEWAYS_KEY; use fedimint_core::db::{AutocommitError, IDatabaseTransactionOpsCoreTyped}; use fedimint_core::task::TaskGroup; -use fedimint_ln_client::LightningClientModule; use fedimint_ln_common::{LightningGateway, LightningGatewayAnnouncement}; use rand::seq::SliceRandom; use rand::thread_rng; use tracing::warn; +use super::client::ClientExt; use super::db::LastActiveGatewayKey; #[derive(Debug, Clone)] pub struct LnGatewayService {} -pub trait ClientExt { - fn ln(&self) -> ClientModuleInstance<'_, LightningClientModule>; -} - -impl ClientExt for Client { - fn ln(&self) -> ClientModuleInstance<'_, LightningClientModule> { - self.get_first_module() - } -} - /// Duration to fetch updates before gateways are about to expire const ABOUT_TO_EXPIRE_DURATION: Duration = Duration::from_secs(30); impl LnGatewayService { pub fn new(client: Arc, task_group: &TaskGroup) -> Self { task_group.spawn_cancellable("gateway_update_cache", async move { - client - .ln() - .update_gateway_cache_continuously(|gws| { + if let Ok(ln) = client.ln() { + ln.update_gateway_cache_continuously(|gws| { Self::maybe_filter_vetted_gateways(&client, gws) }) .await + } }); Self {} } @@ -117,15 +107,16 @@ impl LnGatewayService { &self, client: &Client, ) -> anyhow::Result> { + let ln = client.ln()?; let last_active_gateway_id = self.get_active_gateway(client).await; - let mut gws = Self::selectable_gateways(client, client.ln().list_gateways().await).await; + let mut gws = Self::selectable_gateways(client, ln.list_gateways().await).await; // this should be rare, the background service should keep the gateways updated. if gws.is_empty() { - if let Err(error) = client.ln().update_gateway_cache().await { + if let Err(error) = ln.update_gateway_cache().await { warn!(?error, "updating gateway cache failed"); } - gws = Self::selectable_gateways(client, client.ln().list_gateways().await).await; + gws = Self::selectable_gateways(client, ln.list_gateways().await).await; } if let Some(gw) = gws diff --git a/bridge/fedi-ffi/src/federation_v2/meta.rs b/bridge/fedi-ffi/src/federation_v2/meta.rs index 52996f3..57ddce7 100644 --- a/bridge/fedi-ffi/src/federation_v2/meta.rs +++ b/bridge/fedi-ffi/src/federation_v2/meta.rs @@ -1,9 +1,10 @@ use std::collections::BTreeMap; use std::time::Duration; +use fedimint_api_client::api::DynGlobalApi; use fedimint_client::db::{MetaFieldKey, MetaFieldPrefix, MetaFieldValue, MetaServiceInfoKey}; use fedimint_client::meta::{fetch_meta_overrides, FetchKind, MetaService, MetaSource, MetaValues}; -use fedimint_client::Client; +use fedimint_core::config::ClientConfig; use fedimint_core::db::{Database, IDatabaseTransactionOpsCoreTyped}; use fedimint_core::util::{backon, retry}; use fedimint_core::{apply, async_trait_maybe_send}; @@ -13,11 +14,28 @@ pub type MetaEntries = BTreeMap; #[apply(async_trait_maybe_send)] pub trait MetaServiceExt { + async fn entries(&self, db: &Database) -> Option; async fn entries_from_db(&self, db: &Database) -> Option; } #[apply(async_trait_maybe_send)] impl MetaServiceExt for MetaService { + /// Get all meta entries. + /// + /// This may wait for significant time on first run when there is no cached + /// data. + async fn entries(&self, db: &Database) -> Option { + if let Some(value) = self.entries_from_db(db).await { + // might be from in old cache. + // TODO: maybe old cache should have a ttl? + Some(value) + } else { + // wait for initial value + self.wait_initialization().await; + self.entries_from_db(db).await + } + } + /// Retrieve all meta entries from the database async fn entries_from_db(&self, db: &Database) -> Option { let dbtx = &mut db.begin_transaction_nc().await; @@ -54,16 +72,15 @@ impl MetaSource for LegacyMetaSourceWithExternalUrl { async fn fetch( &self, - client: &Client, + client_config: &ClientConfig, + _api: &DynGlobalApi, fetch_kind: FetchKind, last_revision: Option, ) -> anyhow::Result { - let config_iter = client - .config() - .await + let config_iter = client_config .global .meta - .into_iter() + .iter() .map(|(key, value)| (MetaFieldKey(key.clone()), MetaFieldValue(value.clone()))); let backoff = match fetch_kind { // need to be fast the first time. @@ -77,11 +94,11 @@ impl MetaSource for LegacyMetaSourceWithExternalUrl { .with_max_times(usize::MAX), }; let overrides = retry("fetch_meta_overrides", backoff, || async { - let static_meta = &client.config().await.global.meta; + let static_meta = &client_config.global.meta; if static_meta.contains_key(META_OVERRIDE_URL_FIELD) { - fetch_meta_overrides(&self.reqwest, client, META_OVERRIDE_URL_FIELD).await + fetch_meta_overrides(&self.reqwest, client_config, META_OVERRIDE_URL_FIELD).await } else { - fetch_meta_overrides(&self.reqwest, client, META_EXTERNAL_URL_FIELD).await + fetch_meta_overrides(&self.reqwest, client_config, META_EXTERNAL_URL_FIELD).await } }) .await?; diff --git a/bridge/fedi-ffi/src/federation_v2/mod.rs b/bridge/fedi-ffi/src/federation_v2/mod.rs index 859e565..c63523e 100644 --- a/bridge/fedi-ffi/src/federation_v2/mod.rs +++ b/bridge/fedi-ffi/src/federation_v2/mod.rs @@ -1,3 +1,4 @@ +pub mod client; pub mod db; mod dev; mod meta; @@ -15,7 +16,8 @@ use anyhow::{anyhow, bail, Context, Result}; use bitcoin::address::NetworkUnchecked; use bitcoin::secp256k1::PublicKey; use bitcoin::{Address, Network}; -use db::{FediRawClientConfigKey, InviteCodeKey, TransactionNotesKey, XmppUsernameKey}; +use client::ClientExt; +use db::{FediRawClientConfigKey, InviteCodeKey, TransactionNotesKey}; use fedi_social_client::common::VerificationDocument; use fedi_social_client::{ FediSocialClientInit, RecoveryFile, RecoveryId, SocialBackup, SocialRecoveryClient, @@ -26,9 +28,8 @@ use fedimint_api_client::api::{ }; use fedimint_api_client::download_from_invite_code; use fedimint_bip39::Bip39RootSecretStrategy; -use fedimint_client::backup::{ClientBackup, Metadata}; use fedimint_client::db::ChronologicalOperationLogKey; -use fedimint_client::meta::MetaService; +use fedimint_client::meta::{FetchKind, MetaService, MetaSource}; use fedimint_client::module::recovery::RecoveryProgress; use fedimint_client::module::ClientModule; use fedimint_client::oplog::{OperationLogEntry, UpdateStreamOrOutcome}; @@ -45,40 +46,41 @@ use fedimint_core::module::ApiRequestErased; use fedimint_core::task::{timeout, MaybeSend, MaybeSync, TaskGroup}; use fedimint_core::timing::TimeReporter; use fedimint_core::util::backon::FibonacciBuilder as FibonacciBackoff; -use fedimint_core::{maybe_add_send_sync, Amount, PeerId}; +use fedimint_core::{maybe_add_send_sync, Amount, PeerId, SATS_PER_BITCOIN}; use fedimint_derive_secret::{ChildId, DerivableSecret}; use fedimint_ln_client::{ - InternalPayState, LightningClientInit, LightningClientModule, LightningOperationMeta, - LightningOperationMetaPay, LightningOperationMetaVariant, LnPayState, LnReceiveState, - OutgoingLightningPayment, PayBolt11InvoiceError, PayType, + InternalPayState, LightningClientInit, LightningOperationMeta, LightningOperationMetaPay, + LightningOperationMetaVariant, LnPayState, LnReceiveState, OutgoingLightningPayment, + PayBolt11InvoiceError, PayType, }; use fedimint_ln_common::config::FeeToAmount; use fedimint_ln_common::LightningGateway; +use fedimint_meta_client::MetaModuleMetaSourceWithFallback; use fedimint_mint_client::{ spendable_notes_to_operation_id, MintClientInit, MintClientModule, MintOperationMeta, MintOperationMetaVariant, OOBNotes, ReissueExternalNotesState, SelectNotesWithExactAmount, }; use fedimint_wallet_client::{ - DepositStateV2, PegOutFees, WalletClientInit, WalletClientModule, WalletOperationMeta, - WalletOperationMetaVariant, WithdrawState, + DepositStateV2, PegOutFees, WalletClientInit, WalletOperationMeta, WalletOperationMetaVariant, + WithdrawState, }; use futures::{FutureExt, StreamExt}; use lightning_invoice::{Bolt11Invoice, RoutingFees}; use meta::{LegacyMetaSourceWithExternalUrl, MetaEntries, MetaServiceExt}; use serde::de::DeserializeOwned; use stability_pool_client::{ - ClientAccountInfo, StabilityPoolClientInit, StabilityPoolClientModule, - StabilityPoolDepositOperationState, StabilityPoolMeta, StabilityPoolWithdrawalOperationState, + ClientAccountInfo, StabilityPoolClientInit, StabilityPoolDepositOperationState, + StabilityPoolMeta, StabilityPoolWithdrawalOperationState, }; use tokio::sync::{Mutex, OnceCell}; -use tracing::{error, info, warn, Level}; +use tracing::{error, info, instrument, warn, Level}; use self::backup_service::BackupService; pub use self::backup_service::BackupServiceStatus; use self::db::{ LastStabilityPoolDepositCycleKey, OperationFediFeeStatusKey, OutstandingFediFeesPerTXTypeKey, OutstandingFediFeesPerTXTypeKeyPrefix, PendingFediFeesPerTXTypeKey, - PendingFediFeesPerTXTypeKeyPrefix, + PendingFediFeesPerTXTypeKeyPrefix, TransactionDateFiatInfoKey, }; use self::dev::{ override_localhost, override_localhost_client_config, override_localhost_invite_code, @@ -87,25 +89,25 @@ use self::ln_gateway_service::LnGatewayService; use self::stability_pool_sweeper_service::StabilityPoolSweeperService; use super::constants::{ LIGHTNING_OPERATION_TYPE, MILLION, MINT_OPERATION_TYPE, ONE_WEEK, PAY_INVOICE_TIMEOUT, - REISSUE_ECASH_TIMEOUT, STABILITY_POOL_OPERATION_TYPE, WALLET_OPERATION_TYPE, XMPP_CHILD_ID, - XMPP_KEYPAIR_SEED, XMPP_PASSWORD, + REISSUE_ECASH_TIMEOUT, STABILITY_POOL_OPERATION_TYPE, WALLET_OPERATION_TYPE, }; use super::event::{Event, EventSink, TypedEventExt}; use super::types::{ - federation_v2_to_rpc_federation, FediBackupMetadata, RpcAmount, RpcInvoice, - RpcLightningGateway, RpcPayInvoiceResponse, RpcPublicKey, RpcXmppCredentials, + federation_v2_to_rpc_federation, RpcAmount, RpcInvoice, RpcLightningGateway, + RpcPayInvoiceResponse, RpcPublicKey, }; use crate::error::ErrorCode; use crate::event::RecoveryProgressEvent; use crate::features::FeatureCatalog; use crate::fedi_fee::{FediFeeHelper, FediFeeRemittanceService}; -use crate::storage::FediFeeSchedule; +use crate::storage::{AppState, FediFeeSchedule}; use crate::types::{ EcashReceiveMetadata, EcashSendMetadata, GuardianStatus, LightningSendMetadata, - OperationFediFeeStatus, RpcBitcoinDetails, RpcEcashInfo, RpcFederationId, RpcFeeDetails, - RpcGenerateEcashResponse, RpcLightningDetails, RpcLnState, RpcOOBState, RpcOnchainState, - RpcOperationFediFeeStatus, RpcPayAddressResponse, RpcStabilityPoolTransactionState, - RpcTransaction, RpcTransactionDirection, WithdrawalDetails, + OperationFediFeeStatus, RpcBitcoinDetails, RpcEcashInfo, RpcFederationId, + RpcFederationMaybeLoading, RpcFederationPreview, RpcFeeDetails, RpcGenerateEcashResponse, + RpcLightningDetails, RpcLnState, RpcOOBState, RpcOnchainState, RpcOperationFediFeeStatus, + RpcPayAddressResponse, RpcReturningMemberStatus, RpcStabilityPoolTransactionState, + RpcTransaction, RpcTransactionDirection, TransactionDateFiatInfo, WithdrawalDetails, }; use crate::utils::{display_currency, to_unix_time, unix_now}; @@ -167,14 +169,16 @@ pub struct FederationV2 { // virtual balance) and the time of use (spending ecash and recording fee). pub spend_guard: Arc>, pub feature_catalog: Arc, + pub app_state: Arc, } impl FederationV2 { /// Instantiate Federation from FediConfig async fn build_client_builder(db: Database) -> anyhow::Result { let mut client_builder = fedimint_client::Client::builder(db).await?; - client_builder - .with_meta_service(MetaService::new(LegacyMetaSourceWithExternalUrl::default())); + client_builder.with_meta_service(MetaService::new(MetaModuleMetaSourceWithFallback::new( + LegacyMetaSourceWithExternalUrl::default(), + ))); client_builder.with_module(MintClientInit); client_builder.with_module(LightningClientInit::default()); client_builder.with_module(WalletClientInit(None)); @@ -191,6 +195,7 @@ impl FederationV2 { secret: DerivableSecret, fedi_fee_helper: Arc, feature_catalog: Arc, + app_state: Arc, ) -> Self { let recovering = ng.has_pending_recoveries(); let client = Arc::new(ng); @@ -208,6 +213,7 @@ impl FederationV2 { client, spend_guard: Default::default(), feature_catalog, + app_state, }; if !recovering { federation.start_background_tasks().await; @@ -248,6 +254,8 @@ impl FederationV2 { let federation = self.clone(); self.task_group .spawn_cancellable("send_meta_updates", async move { + federation.client.meta_service().wait_initialization().await; + federation.send_federation_event().await; let mut subscribe_to_updates = std::pin::pin!(federation.client.meta_service().subscribe_to_updates()); while subscribe_to_updates.next().await.is_some() { @@ -259,10 +267,7 @@ impl FederationV2 { // seeks don't accidentally disappear if a test takes longer than expected and a // stability pool cycle elapses during the course of the test. #[cfg(not(test))] - if self - .client - .get_first_instance(&stability_pool_client::common::KIND) - .is_some() + if self.client.sp().is_ok() && self .stability_pool_sweeper_service .set(StabilityPoolSweeperService::new( @@ -295,6 +300,7 @@ impl FederationV2 { } /// Instantiate Federation from FediConfig + #[allow(clippy::too_many_arguments)] pub async fn from_db( db: Database, event_sink: EventSink, @@ -303,6 +309,7 @@ impl FederationV2 { device_index: u8, fedi_fee_helper: Arc, feature_catalog: Arc, + app_state: Arc, ) -> anyhow::Result { let client_builder = Self::build_client_builder(db.clone()).await?; let config = Client::get_config_from_db(&db) @@ -330,21 +337,23 @@ impl FederationV2 { auxiliary_secret, fedi_fee_helper, feature_catalog, + app_state, ) .await) } - pub async fn download_client_config( - invite_code_string: &str, + pub async fn federation_preview( + invite_code: &str, root_mnemonic: &bip39::Mnemonic, device_index: u8, should_override_localhost: bool, - ) -> anyhow::Result<(ClientConfig, anyhow::Result>)> { - let mut invite_code: InviteCode = InviteCode::from_str(invite_code_string)?; + ) -> Result { + let invite_code = invite_code.to_lowercase(); + let mut invite_code = InviteCode::from_str(&invite_code)?; if should_override_localhost { override_localhost_invite_code(&mut invite_code); } - let api = DynGlobalApi::from_invite_code(&invite_code); + let api_single_gaurdian = DynGlobalApi::from_invite_code(&invite_code); let client_root_sercet = { let federation_id = invite_code.federation_id(); // We do an additional derivation using `DerivableSecret::federation_key` since @@ -352,17 +361,44 @@ impl FederationV2 { Self::client_root_secret_from_root_mnemonic(root_mnemonic, &federation_id, device_index) .federation_key(&federation_id) }; - - // we only use this to check if backup exists let decoders = ModuleDecoderRegistry::default().with_fallback(); let (client_config, backup) = tokio::join!( download_from_invite_code(&invite_code), - Client::download_backup_from_federation_static(&api, &client_root_sercet, &decoders,) + Client::download_backup_from_federation_static( + &api_single_gaurdian, + &client_root_sercet, + &decoders, + ) ); + let config = client_config.context("failed to connect")?; - Ok((client_config?, backup)) - } + let meta_source = + MetaModuleMetaSourceWithFallback::new(LegacyMetaSourceWithExternalUrl::default()); + let meta = meta_source + .fetch(&config, &api_single_gaurdian, FetchKind::Initial, None) + .await? + .values + .into_iter() + .map(|(k, v)| (k.0, v.0)) + .collect(); + Ok(RpcFederationPreview { + id: RpcFederationId(config.global.calculate_federation_id().to_string()), + name: config + .global + .federation_name() + .map(|x| x.to_owned()) + .unwrap_or(config.global.calculate_federation_id().to_string()[0..8].to_string()), + meta, + invite_code: invite_code.to_string(), + version: 2, + returning_member_status: match backup { + Ok(Some(_)) => RpcReturningMemberStatus::ReturningMember, + Ok(None) => RpcReturningMemberStatus::NewMember, + Err(_) => RpcReturningMemberStatus::Unknown, + }, + }) + } /// Download federation configs using an invite code. Save client config to /// correct database with Storage. #[allow(clippy::too_many_arguments)] @@ -376,6 +412,7 @@ impl FederationV2 { recover_from_scratch: bool, fedi_fee_helper: Arc, feature_catalog: Arc, + app_state: Arc, ) -> Result { let mut invite_code = InviteCode::from_str(&invite_code_string).context("invalid invite code")?; @@ -426,11 +463,11 @@ impl FederationV2 { .await? } else { info!("backup not found"); + // FIXME: api secret client_builder .join(client_secret, client_config, None) .await? }; - let metadata = client.get_metadata().await; let this = Self::new( client, event_sink, @@ -438,9 +475,9 @@ impl FederationV2 { auxiliary_secret, fedi_fee_helper, feature_catalog, + app_state, ) .await; - this.save_restored_metadata(metadata).await?; Ok(this) } @@ -468,11 +505,7 @@ impl FederationV2 { if self.recovering() { None } else { - Some( - self.client - .get_first_module::() - .get_network(), - ) + self.client.wallet().map(|module| module.get_network()).ok() } } @@ -485,14 +518,32 @@ impl FederationV2 { } pub async fn get_cached_meta(&self) -> MetaEntries { - match self - .client - .meta_service() - .entries_from_db(self.client.db()) - .await + let cfg_fetcher = async { self.client.config().await.global.meta }; + + // Wait at most 2s for very first meta fetching + match timeout( + Duration::from_secs(2), + self.client.meta_service().entries(self.client.db()), + ) + .await { - Some(entries) => entries, - None => self.client.config().await.global.meta, + Ok(Some(entries)) => entries, + Ok(None) => cfg_fetcher.await, + Err(_) => { + warn!( + "Timeout when fetching meta for federation ID {}", + self.federation_id() + ); + match self + .client + .meta_service() + .entries_from_db(self.client.db()) + .await + { + Some(entries) => entries, + None => cfg_fetcher.await, + } + } } } @@ -518,13 +569,25 @@ impl FederationV2 { if self.recovering() { return Amount::ZERO; } - let mint_client = self.client.get_first_module::(); + let Ok(mint_client) = self.client.mint() else { + return Amount::ZERO; + }; let mut dbtx = mint_client.db.begin_transaction_nc().await; - mint_client + let raw_fedimint_balance = mint_client .get_wallet_summary(&mut dbtx) .await - .total_amount() - - (self.get_outstanding_fedi_fees().await + self.get_pending_fedi_fees().await) + .total_amount(); + let fedi_fee_sum = + self.get_outstanding_fedi_fees().await + self.get_pending_fedi_fees().await; + if raw_fedimint_balance < fedi_fee_sum { + warn!( + "Fee {} is somehow greater than fm balance {} for federation {}", + fedi_fee_sum, + raw_fedimint_balance, + self.federation_id() + ); + } + raw_fedimint_balance.saturating_sub(fedi_fee_sum) } pub async fn guardian_status(&self) -> anyhow::Result> { @@ -637,21 +700,21 @@ impl FederationV2 { /// Generate bitcoin address pub async fn generate_address(&self) -> Result { // FIXME: add fedi fees once fedimint await primary module outputs - // let fedi_fee_ppm = self - // .fedi_fee_helper - // .get_fedi_fee_ppm( - // self.federation_id().to_string(), - // fedimint_wallet_client::KIND, - // RpcTransactionDirection::Receive, - // ) - // .await?; + let fedi_fee_ppm = self + .fedi_fee_helper + .get_fedi_fee_ppm( + self.federation_id().to_string(), + fedimint_wallet_client::KIND, + RpcTransactionDirection::Receive, + ) + .await?; let (operation_id, address, _) = self .client - .get_first_module::() + .wallet()? .allocate_deposit_address_expert_only(()) .await?; - // self.write_pending_receive_fedi_fee_ppm(operation_id, fedi_fee_ppm) - // .await?; + self.write_pending_receive_fedi_fee_ppm(operation_id, fedi_fee_ppm) + .await?; self.subscribe_deposit(operation_id, address.to_string()) .await?; @@ -677,7 +740,7 @@ impl FederationV2 { let gateway = self.select_gateway().await?; let (operation_id, invoice, _) = self .client - .get_first_module::() + .ln()? .create_bolt11_invoice( amount.0, lightning_invoice::Bolt11InvoiceDescription::Direct( @@ -691,6 +754,7 @@ impl FederationV2 { self.write_pending_receive_fedi_fee_ppm(operation_id, fedi_fee_ppm) .await?; + let _ = self.record_tx_date_fiat_info(operation_id, amount.0).await; self.subscribe_invoice(operation_id, invoice.clone()) .await?; @@ -712,7 +776,10 @@ impl FederationV2 { fed.task_group .clone() .spawn_cancellable("subscribe deposit", async move { - let wallet = fed.client.get_first_module::(); + let Ok(wallet) = fed.client.wallet() else { + error!("Wallet module not present!"); + return; + }; let Ok(mut updates) = wallet .subscribe_deposit(operation_id) .await @@ -723,6 +790,13 @@ impl FederationV2 { else { return; }; + let pending_fedi_fee_status = fed + .client + .db() + .begin_transaction_nc() + .await + .get_value(&OperationFediFeeStatusKey(operation_id)) + .await; while let Some(update) = updates.next().await { info!("Update: {:?}", update); fed.update_operation_state(operation_id, update.clone()) @@ -730,36 +804,40 @@ impl FederationV2 { let deposit_outcome = update.clone(); match update { DepositStateV2::WaitingForConfirmation { btc_deposited, .. } + | DepositStateV2::Confirmed { btc_deposited, .. } | DepositStateV2::Claimed { btc_deposited, .. } => { - let fees = wallet.get_fee_consensus().peg_in_abs; - let amount = Amount::from_sats(btc_deposited.to_sat()) - fees; + let federation_fees = wallet.get_fee_consensus().peg_in_abs; + let amount = + Amount::from_sats(btc_deposited.to_sat()) - federation_fees; // FIXME: add fedi fees once fedimint await primary module outputs - // let fedi_fee_status = fed - // .write_success_receive_fedi_fee(operation_id, amount) - // .await - // .map(|(_, status)| status) - // .ok() - // .map(Into::into); - let onchain_details = Some(RpcBitcoinDetails { + let fedi_fee_status = if let DepositStateV2::Claimed { .. } = &update { + fed.write_success_receive_fedi_fee(operation_id, amount) + .await + .map(|(_, status)| status) + .ok() + } else { + pending_fedi_fee_status.clone() + }; + let _ = fed.record_tx_date_fiat_info(operation_id, amount).await; + let tx_date_fiat_info = fed + .dbtx() + .await + .get_value(&TransactionDateFiatInfoKey(operation_id)) + .await; + let transaction = RpcTransaction::new( + operation_id.fmt_full().to_string(), + unix_now().expect("unix time should exist"), + RpcAmount(amount), + RpcTransactionDirection::Receive, + fedi_fee_status.map(Into::into), + tx_date_fiat_info, + ) + .with_onchain_state(RpcOnchainState::from_deposit_state( + deposit_outcome, + )) + .with_bitcoin(RpcBitcoinDetails { address: address.clone(), }); - let transaction = RpcTransaction { - id: operation_id.fmt_full().to_string(), - created_at: unix_now().expect("unix time should exist"), - amount: RpcAmount(amount), - fedi_fee_status: None, - direction: RpcTransactionDirection::Receive, - notes: "".into(), - onchain_state: RpcOnchainState::from_deposit_state(Some( - deposit_outcome, - )), - bitcoin: onchain_details, - ln_state: None, - lightning: None, - oob_state: None, - onchain_withdrawal_details: None, - stability_pool_state: None, - }; info!("send_transaction_event: {:?}", transaction); fed.send_transaction_event(transaction); } @@ -785,13 +863,15 @@ impl FederationV2 { self.task_group .clone() .spawn("subscribe invoice", move |_| async move { - let mut updates = fed - .client - .get_first_module::() - .subscribe_ln_receive(operation_id) - .await - .unwrap() // FIXME - .into_stream(); + let Ok(ln) = fed.client.ln() else { + error!("Lightning module not found!"); + return; + }; + let Ok(updates) = ln.subscribe_ln_receive(operation_id).await else { + error!("Lightning operation with ID {:?} not found!", operation_id); + return; + }; + let mut updates = updates.into_stream(); while let Some(update) = updates.next().await { info!("Update: {:?}", update); match update { @@ -805,24 +885,24 @@ impl FederationV2 { .map(|(_, status)| status) .ok() .map(Into::into); - let transaction = RpcTransaction { - id: operation_id.fmt_full().to_string(), - created_at: unix_now().expect("unix time should exist"), - amount: RpcAmount(amount), + let tx_date_fiat_info = fed + .dbtx() + .await + .get_value(&TransactionDateFiatInfoKey(operation_id)) + .await; + let transaction = RpcTransaction::new( + operation_id.fmt_full().to_string(), + unix_now().expect("unix time should exist"), + RpcAmount(amount), + RpcTransactionDirection::Receive, fedi_fee_status, - direction: RpcTransactionDirection::Receive, - notes: "".into(), - bitcoin: None, - onchain_state: None, - ln_state: RpcLnState::from_ln_recv_state(Some(update)), - lightning: Some(RpcLightningDetails { - invoice: invoice.to_string(), - fee: None, - }), - oob_state: None, - onchain_withdrawal_details: None, - stability_pool_state: None, - }; + tx_date_fiat_info, + ) + .with_ln_state(RpcLnState::from_ln_recv_state(update)) + .with_lightning(RpcLightningDetails { + invoice: invoice.to_string(), + fee: None, + }); fed.send_transaction_event(transaction); } LnReceiveState::Canceled { reason } => { @@ -864,7 +944,7 @@ impl FederationV2 { if !is_internal_payment { let gateways = self .client - .get_first_module::() + .ln()? .list_gateways() .await .into_iter() @@ -935,7 +1015,8 @@ impl FederationV2 { let spend_guard = self.spend_guard.lock().await; let virtual_balance = self.get_balance().await; - if amount.msats + fedi_fee + network_fee > virtual_balance.msats { + let est_total_spend = amount.msats + fedi_fee + network_fee; + if est_total_spend > virtual_balance.msats { bail!(ErrorCode::InsufficientBalance(RpcAmount( get_max_spendable_amount(virtual_balance, fedi_fee_ppm, None, Some(gateway_fees)) ))); @@ -945,7 +1026,7 @@ impl FederationV2 { let extra_meta = LightningSendMetadata { is_fedi_fee_remittance: false, }; - let ln = self.client.get_first_module::(); + let ln = self.client.ln()?; let OutgoingLightningPayment { payment_type, .. } = match ln .pay_bolt11_invoice(gateway, invoice.to_owned(), extra_meta.clone()) .await @@ -978,6 +1059,12 @@ impl FederationV2 { .await?; drop(spend_guard); + let _ = self + .record_tx_date_fiat_info( + payment_type.operation_id(), + Amount::from_msats(est_total_spend), + ) + .await; let response = timeout( PAY_INVOICE_TIMEOUT, self.subscribe_to_ln_pay(payment_type, extra_meta, invoice.clone()), @@ -1005,7 +1092,7 @@ impl FederationV2 { .await?; let network_fees = self .client - .get_first_module::() + .wallet()? .get_withdraw_fees(address.clone(), amount) .await?; @@ -1033,6 +1120,7 @@ impl FederationV2 { address: Address, amount: bitcoin::Amount, ) -> Result { + let wallet = self.client.wallet()?; let fedi_fee_ppm = self .fedi_fee_helper .get_fedi_fee_ppm( @@ -1041,11 +1129,7 @@ impl FederationV2 { RpcTransactionDirection::Send, ) .await?; - let network_fees = self - .client - .get_first_module::() - .get_withdraw_fees(address.clone(), amount) - .await?; + let network_fees = wallet.get_withdraw_fees(address.clone(), amount).await?; let amount_msat = amount.to_sat() * 1000; let fedi_fee = (amount_msat * fedi_fee_ppm).div_ceil(MILLION); @@ -1060,17 +1144,14 @@ impl FederationV2 { ))); } - let operation_id = self - .client - .get_first_module::() - .withdraw(address, amount, network_fees, ()) - .await?; + let operation_id = wallet.withdraw(address, amount, network_fees, ()).await?; self.write_pending_send_fedi_fee(operation_id, Amount::from_msats(fedi_fee)) .await?; drop(spend_guard); - let mut updates = self - .client - .get_first_module::() + let _ = self + .record_tx_date_fiat_info(operation_id, Amount::from_msats(est_total_spend)) + .await; + let mut updates = wallet .subscribe_withdraw_updates(operation_id) .await? .into_stream(); @@ -1101,12 +1182,10 @@ impl FederationV2 { &self, operation_id: OperationId, ) -> Option<(WithdrawState, Option)> { - let mut updates = match self - .client - .get_first_module::() - .subscribe_withdraw_updates(operation_id) - .await - { + let Ok(wallet) = self.client.wallet() else { + return None; + }; + let mut updates = match wallet.subscribe_withdraw_updates(operation_id).await { Err(_) => return None, Ok(stream) => stream.into_stream(), }; @@ -1304,14 +1383,10 @@ impl FederationV2 { extra_meta: LightningSendMetadata, _invoice: Bolt11Invoice, ) -> Result { + let ln = self.client.ln()?; match pay_type { PayType::Internal(operation_id) => { - let mut updates = self - .client - .get_first_module::() - .subscribe_internal_pay(operation_id) - .await? - .into_stream(); + let mut updates = ln.subscribe_internal_pay(operation_id).await?.into_stream(); while let Some(update) = updates.next().await { // Skip updating fee status if payment is for fee remittance @@ -1358,12 +1433,7 @@ impl FederationV2 { Err(anyhow!("Internal lightning payment failed")) } PayType::Lightning(operation_id) => { - let mut updates = self - .client - .get_first_module::() - .subscribe_ln_pay(operation_id) - .await? - .into_stream(); + let mut updates = ln.subscribe_ln_pay(operation_id).await?.into_stream(); while let Some(update) = updates.next().await { self.update_operation_state(operation_id, update.clone()) .await; @@ -1414,9 +1484,9 @@ impl FederationV2 { /// "federation" events when one is observed async fn subscribe_balance_updates(&mut self) { let federation = self.clone(); - self.task_group.spawn( + self.task_group.spawn_cancellable( format!("{:?} balance subscription", federation.federation_name()), - |_| async move { + async move { // always send an initial balance event federation.send_balance_event().await; let mut updates = federation.client.subscribe_balance_changes().await; @@ -1481,10 +1551,10 @@ impl FederationV2 { )); } - /// Send whenever social recovery state changes + /// Send whenever federation meta keys change pub async fn send_federation_event(&self) { let rpc_federation = federation_v2_to_rpc_federation(&Arc::new(self.clone())).await; - let event = Event::federation(rpc_federation); + let event = Event::federation(RpcFederationMaybeLoading::Ready(rpc_federation)); self.event_sink.typed_event(&event); } @@ -1494,11 +1564,7 @@ impl FederationV2 { /// List all lightning gateways registered with the federation pub async fn list_gateways(&self) -> anyhow::Result> { - let gateways = self - .client - .get_first_module::() - .list_gateways() - .await; + let gateways = self.client.ln()?.list_gateways().await; let active_gw = self .gateway_service()? .get_active_gateway(&self.client) @@ -1531,11 +1597,12 @@ impl FederationV2 { // TODO: include metadata as 2nd argument let operation_id = self .client - .get_first_module::() + .mint()? .reissue_external_notes(ecash, meta) .await?; self.write_pending_receive_fedi_fee_ppm(operation_id, fedi_fee_ppm) .await?; + let _ = self.record_tx_date_fiat_info(operation_id, amount).await; self.subscribe_to_operation(operation_id).await?; Ok(amount) } @@ -1589,7 +1656,7 @@ impl FederationV2 { .map_or(false, |x| x.internal); let mut updates = self .client - .get_first_module::() + .mint()? .subscribe_reissue_external_notes(operation_id) .await .unwrap() @@ -1622,21 +1689,21 @@ impl FederationV2 { .get_value(&OperationFediFeeStatusKey(operation_id)) .await .map(Into::into); - self.send_transaction_event(RpcTransaction { - id: operation_id.fmt_full().to_string(), - created_at: unix_now().expect("unix time should exist"), - direction: RpcTransactionDirection::Receive, - notes: "".into(), - onchain_state: None, - bitcoin: None, - ln_state: None, - amount: RpcAmount(meta.amount), - lightning: None, - oob_state: Some(RpcOOBState::from_reissue_v2(update.clone())), - onchain_withdrawal_details: None, - stability_pool_state: None, + let tx_date_fiat_info = self + .dbtx() + .await + .get_value(&TransactionDateFiatInfoKey(operation_id)) + .await; + let transaction = RpcTransaction::new( + operation_id.fmt_full().to_string(), + unix_now().expect("unix time should exist"), + RpcAmount(meta.amount), + RpcTransactionDirection::Receive, fedi_fee_status, - }); + tx_date_fiat_info, + ) + .with_oob_state(RpcOOBState::from_reissue_v2(update.clone())); + self.send_transaction_event(transaction); } if let ReissueExternalNotesState::Failed(e) = update { updates.next().await; @@ -1665,15 +1732,15 @@ impl FederationV2 { ))); } + let mint = self.client.mint()?; + // If generating EXACT amount works, use those notes. Otherwise, generate using // AT LEAST strategy, marking it as internal TX (so we can filter it out). // Immediately cancel, which will reissue the notes attempting to fill in lower // denominations. And then generate using AT LEAST strategy again, which // will now have a high chance to producing the exact amount. let cancel_time = fedimint_core::time::now() + ONE_WEEK; - let (spend_guard, operation_id, notes) = match self - .client - .get_first_module::() + let (spend_guard, operation_id, notes) = match mint .spend_notes_with_selector( &SelectNotesWithExactAmount, amount, @@ -1685,9 +1752,7 @@ impl FederationV2 { { Ok((operation_id, notes)) => (spend_guard, operation_id, notes), Err(_) => { - let (_, notes) = self - .client - .get_first_module::() + let (_, notes) = mint .spend_notes( amount, ONE_WEEK, @@ -1700,9 +1765,7 @@ impl FederationV2 { // try to make change timeout(REISSUE_ECASH_TIMEOUT, async { let notes_amount = notes.total_amount(); - let operation_id = self - .client - .get_first_module::() + let operation_id = mint .reissue_external_notes(notes, EcashReceiveMetadata { internal: true }) .await?; self.subscribe_to_ecash_reissue(operation_id, notes_amount) @@ -1718,9 +1781,7 @@ impl FederationV2 { get_max_spendable_amount(virtual_balance, fedi_fee_ppm, None, None) ))); } - let (operation_id, notes) = self - .client - .get_first_module::() + let (operation_id, notes) = mint .spend_notes( amount, ONE_WEEK, @@ -1737,6 +1798,10 @@ impl FederationV2 { // spend_guard must be dropped after writing fee since virtual balance only // updates once fee is written drop(spend_guard); + + let _ = self + .record_tx_date_fiat_info(operation_id, amount + fedi_fee) + .await; self.subscribe_to_operation(operation_id).await?; Ok(RpcGenerateEcashResponse { @@ -1749,10 +1814,7 @@ impl FederationV2 { let op_id = spendable_notes_to_operation_id(ecash.notes()); // NOTE: try_cancel_spend_notes itself is not presisted across restarts. // it uses inmemory channel. - self.client - .get_first_module::() - .try_cancel_spend_notes(op_id) - .await; + self.client.mint()?.try_cancel_spend_notes(op_id).await; self.subscribe_oob_spend(op_id).await?; Ok(()) } @@ -1760,7 +1822,7 @@ impl FederationV2 { async fn subscribe_oob_spend(&self, op_id: OperationId) -> Result<(), anyhow::Error> { let mut updates = self .client - .get_first_module::() + .mint()? .subscribe_spend_notes(op_id) .await? .into_stream(); @@ -1834,19 +1896,6 @@ impl FederationV2 { Ok(self.backup_service.status(&self.client).await) } - /// Extract username (and potentially more in future) from recovered - /// metadata and save it to database - pub async fn save_restored_metadata(&self, metadata: Metadata) -> Result<()> { - if let Ok(fedi_backup_metadata) = metadata.to_json_deserialized::() { - if let Some(username) = fedi_backup_metadata.username { - self.save_xmpp_username(&username).await?; - } - } else { - warn!("failed to parse metadata"); - }; - Ok(()) - } - // // Social Recovery // @@ -1858,11 +1907,11 @@ impl FederationV2 { root_secret.child_key(SOCIAL_RECOVERY_SECRET_CHILD_ID) } - fn social_api(&self) -> DynModuleApi { + fn social_api(&self) -> anyhow::Result { let social_module = self .client - .get_first_module::(); - social_module.api + .try_get_first_module::()?; + Ok(social_module.api) } pub async fn decoded_config(&self) -> Result { @@ -1887,12 +1936,16 @@ impl FederationV2 { .get_first_module_by_kind::( "fedi-social", ) - .expect("needs social recovery module client config"); + .map_err(|_| { + anyhow!(ErrorCode::ModuleNotFound( + fedi_social_client::KIND.to_string() + )) + })?; Ok(SocialBackup { module_secret: Self::social_recovery_secret_static(&self.root_secret()), module_id, config: cfg.clone(), - api: self.social_api(), + api: self.social_api()?, }) } @@ -1906,8 +1959,12 @@ impl FederationV2 { .get_first_module_by_kind::( "fedi-social", ) - .expect("needs social recovery module client config"); - SocialRecoveryClient::new_start(module_id, cfg.clone(), self.social_api(), recovery_file) + .map_err(|_| { + anyhow!(ErrorCode::ModuleNotFound( + fedi_social_client::KIND.to_string() + )) + })?; + SocialRecoveryClient::new_start(module_id, cfg.clone(), self.social_api()?, recovery_file) } /// Continue social recovery session @@ -1920,18 +1977,22 @@ impl FederationV2 { .get_first_module_by_kind::( "fedi-social", ) - .expect("needs social recovery module client config"); + .map_err(|_| { + anyhow!(ErrorCode::ModuleNotFound( + fedi_social_client::KIND.to_string() + )) + })?; Ok(SocialRecoveryClient::new_continue( module_id, cfg.clone(), - self.social_api(), + self.social_api()?, prev_state, )) } /// Get social verification client for a guardian pub async fn social_verification(&self, peer_id: PeerId) -> Result { - Ok(SocialVerification::new(self.social_api(), peer_id)) + Ok(SocialVerification::new(self.social_api()?, peer_id)) } /// Upload social recovery recovery file to federation given a recovery @@ -1999,26 +2060,6 @@ impl FederationV2 { Ok(()) } - /// Returns an XMPP password derived from client secret. This enables - /// recovery of XMPP account after recovering wallet. - pub async fn get_xmpp_credentials(&self) -> RpcXmppCredentials { - let root_secret = self.root_secret(); - let xmpp_secret = root_secret.child_key(ChildId(XMPP_CHILD_ID)); - let password_bytes: [u8; 16] = xmpp_secret - .child_key(ChildId(XMPP_PASSWORD)) - .to_random_bytes(); - let keypair_seed_bytes: [u8; 32] = xmpp_secret - .child_key(ChildId(XMPP_KEYPAIR_SEED)) - .to_random_bytes(); - let username = self.get_xmpp_username().await; - - RpcXmppCredentials { - password: hex::encode(password_bytes), - keypair_seed: hex::encode(keypair_seed_bytes), - username, - } - } - pub async fn get_ln_pay_outcome( &self, operation_id: OperationId, @@ -2050,12 +2091,11 @@ impl FederationV2 { return Some(outcome); } - match self - .client - .get_first_module::() - .subscribe_deposit(operation_id) - .await - { + let Ok(wallet) = self.client.wallet() else { + return None; + }; + + match wallet.subscribe_deposit(operation_id).await { Err(_) => None, Ok(UpdateStreamOrOutcome::Outcome(outcome)) => Some(outcome), // don't block @@ -2102,6 +2142,7 @@ impl FederationV2 { .get_value(&TransactionNotesKey(op.0.operation_id)) .await .unwrap_or_default(); + let tx_date_fiat_info = self.dbtx().await.get_value(&TransactionDateFiatInfoKey(op.0.operation_id)).await; let fedi_fee_status = self .client .db() @@ -2128,119 +2169,110 @@ impl FederationV2 { if extra_meta.is_fedi_fee_remittance { None } else { - Some(RpcTransaction { - id: op.0.operation_id.fmt_full().to_string(), - created_at: to_unix_time(op.0.creation_time) - .expect("unix time should exist"), - amount: RpcAmount(Amount { + let mut transaction = RpcTransaction::new( + op.0.operation_id.fmt_full().to_string(), + to_unix_time(op.0.creation_time).expect("unix time should exist"), + RpcAmount(Amount { msats: invoice.amount_milli_satoshis().unwrap() + fedi_fee_msats + fee.msats, }), + RpcTransactionDirection::Send, fedi_fee_status, - direction: RpcTransactionDirection::Send, - notes, - onchain_state: None, - bitcoin: None, - ln_state: RpcLnState::from_ln_pay_state( - self.get_ln_pay_outcome(op.0.operation_id, op.1).await, - ), - lightning: Some(RpcLightningDetails { - invoice: invoice.to_string(), - fee: Some(RpcAmount(fee)), - }), - oob_state: None, - onchain_withdrawal_details: None, - stability_pool_state: None, - }) + tx_date_fiat_info + ) + .with_notes(notes) + .with_lightning(RpcLightningDetails { + invoice: invoice.to_string(), + fee: Some(RpcAmount(fee)), + }); + + if let Some(state) = self.get_ln_pay_outcome(op.0.operation_id, op.1).await { + transaction = transaction.with_ln_state(RpcLnState::from_ln_pay_state(state)); + } + Some(transaction) } } LightningOperationMetaVariant::Receive{ invoice, .. } => { - let ln_state = RpcLnState::from_ln_recv_state( - op.1.outcome::(), - ); - Some(RpcTransaction { - id: op.0.operation_id.fmt_full().to_string(), - created_at: to_unix_time(op.0.creation_time) - .expect("unix time should exist"), - amount: RpcAmount(Amount { + let mut transaction = RpcTransaction::new( + op.0.operation_id.fmt_full().to_string(), + to_unix_time(op.0.creation_time).expect("unix time should exist"), + RpcAmount(Amount { msats: invoice.amount_milli_satoshis().unwrap(), }), + RpcTransactionDirection::Receive, fedi_fee_status, - direction: RpcTransactionDirection::Receive, - notes, - onchain_state: None, - bitcoin: None, - ln_state, - lightning: Some(RpcLightningDetails { - invoice: invoice.to_string(), - fee: None, - }), - oob_state: None, - onchain_withdrawal_details: None, - stability_pool_state: None, - }) + tx_date_fiat_info + ) + .with_notes(notes) + .with_lightning(RpcLightningDetails { + invoice: invoice.to_string(), + fee: None, + }); + + if let Some(state) = op.1.outcome::() { + transaction = transaction.with_ln_state(RpcLnState::from_ln_recv_state(state)); + } + Some(transaction) } LightningOperationMetaVariant::Claim { .. } => unreachable!("claims are not supported"), } }, STABILITY_POOL_OPERATION_TYPE => match op.1.meta() { StabilityPoolMeta::Deposit { txid, amount, .. } => { - let stability_pool_state = match self.stability_pool_account_info(false).await { - Ok(ClientAccountInfo { account_info, .. }) => if let Some(metadata) = account_info.seeks_metadata.get(&txid) { - Some(RpcStabilityPoolTransactionState::CompleteDeposit { initial_amount_cents: metadata.initial_amount_cents, fees_paid_so_far: RpcAmount(metadata.fees_paid_so_far) }) + let mut transaction = RpcTransaction::new( + op.0.operation_id.fmt_full().to_string(), + to_unix_time(op.0.creation_time).expect("unix time should exist"), + RpcAmount(amount + Amount::from_msats(fedi_fee_msats)), + RpcTransactionDirection::Send, + fedi_fee_status, + tx_date_fiat_info + ) + .with_notes(notes); + + if let Ok(ClientAccountInfo { account_info, .. }) = self.stability_pool_account_info(false).await { + let state = if let Some(metadata) = account_info.seeks_metadata.get(&txid) { + RpcStabilityPoolTransactionState::CompleteDeposit { + initial_amount_cents: metadata.initial_amount_cents, + fees_paid_so_far: RpcAmount(metadata.fees_paid_so_far) + } } else { - Some(RpcStabilityPoolTransactionState::PendingDeposit) - }, - Err(_) => None, - }; - Some(RpcTransaction { - id: op.0.operation_id.fmt_full().to_string(), - created_at: to_unix_time(op.0.creation_time) - .expect("unix time should exist"), - amount: RpcAmount(amount + Amount::from_msats(fedi_fee_msats)), - fedi_fee_status, - direction: RpcTransactionDirection::Send, - notes, - onchain_state: None, - bitcoin: None, - ln_state: None, - lightning: None, - oob_state: None, - onchain_withdrawal_details: None, - stability_pool_state, - })}, + RpcStabilityPoolTransactionState::PendingDeposit + }; + transaction = transaction.with_stability_pool_state(state); + } + Some(transaction) + }, StabilityPoolMeta::Withdrawal { estimated_withdrawal_cents, .. } | StabilityPoolMeta::CancelRenewal { estimated_withdrawal_cents, .. } => { let outcome = self .get_client_operation_outcome(op.0.operation_id, op.1) .await; - Some(RpcTransaction { - id: op.0.operation_id.fmt_full().to_string(), - created_at: to_unix_time(op.0.creation_time) - .expect("unix time should exist"), - amount: match outcome { - Some(StabilityPoolWithdrawalOperationState::WithdrawUnlockedInitiated(amount) | - StabilityPoolWithdrawalOperationState::WithdrawUnlockedAccepted(amount) | - StabilityPoolWithdrawalOperationState::Success(amount) | - StabilityPoolWithdrawalOperationState::CancellationInitiated(Some(amount)) | - StabilityPoolWithdrawalOperationState::CancellationAccepted(Some(amount)) | - StabilityPoolWithdrawalOperationState::WithdrawIdleInitiated(amount) | - StabilityPoolWithdrawalOperationState::WithdrawIdleAccepted(amount)) => RpcAmount(amount), - _ => RpcAmount(Amount::ZERO), - }, + let amount = match outcome { + Some(StabilityPoolWithdrawalOperationState::WithdrawUnlockedInitiated(amount) | + StabilityPoolWithdrawalOperationState::WithdrawUnlockedAccepted(amount) | + StabilityPoolWithdrawalOperationState::Success(amount) | + StabilityPoolWithdrawalOperationState::CancellationInitiated(Some(amount)) | + StabilityPoolWithdrawalOperationState::CancellationAccepted(Some(amount)) | + StabilityPoolWithdrawalOperationState::WithdrawIdleInitiated(amount) | + StabilityPoolWithdrawalOperationState::WithdrawIdleAccepted(amount)) => RpcAmount(amount), + _ => RpcAmount(Amount::ZERO), + }; + let mut transaction = RpcTransaction::new( + op.0.operation_id.fmt_full().to_string(), + to_unix_time(op.0.creation_time).expect("unix time should exist"), + amount, + RpcTransactionDirection::Receive, fedi_fee_status, - direction: RpcTransactionDirection::Receive, - notes, - onchain_state: None, - bitcoin: None, - ln_state: None, - lightning: None, - oob_state: None, - onchain_withdrawal_details: None, - stability_pool_state: match outcome { - Some(StabilityPoolWithdrawalOperationState::Success(_)) => Some(RpcStabilityPoolTransactionState::CompleteWithdrawal { estimated_withdrawal_cents }), - Some(_) => Some(RpcStabilityPoolTransactionState::PendingWithdrawal { estimated_withdrawal_cents }), - None => None, - } - }) + tx_date_fiat_info + ) + .with_notes(notes); + + if let Some(outcome) = outcome { + let state = match outcome { + StabilityPoolWithdrawalOperationState::Success(_) => RpcStabilityPoolTransactionState::CompleteWithdrawal { estimated_withdrawal_cents }, + _ => RpcStabilityPoolTransactionState::PendingWithdrawal { estimated_withdrawal_cents }, + }; + transaction = transaction.with_stability_pool_state(state); + } + Some(transaction) } }, MINT_OPERATION_TYPE => { @@ -2252,25 +2284,21 @@ impl FederationV2 { ) .map_or(false, |x| x.internal); if !internal { - Some(RpcTransaction { - id: op.0.operation_id.fmt_full().to_string(), - created_at: to_unix_time(op.0.creation_time) - .expect("unix time should exist"), - direction: RpcTransactionDirection::Receive, - notes, - onchain_state: None, - bitcoin: None, - ln_state: None, - amount: RpcAmount(mint_meta.amount), + let mut transaction = RpcTransaction::new( + op.0.operation_id.fmt_full().to_string(), + to_unix_time(op.0.creation_time).expect("unix time should exist"), + RpcAmount(mint_meta.amount), + RpcTransactionDirection::Receive, fedi_fee_status, - lightning: None, - oob_state: self - .get_client_operation_outcome(op.0.operation_id, op.1) - .await - .map(RpcOOBState::from_reissue_v2), - onchain_withdrawal_details: None, - stability_pool_state: None, - }) + tx_date_fiat_info + ) + .with_notes(notes); + + if let Some(outcome) = self.get_client_operation_outcome(op.0.operation_id, op.1).await { + let state = RpcOOBState::from_reissue_v2(outcome); + transaction = transaction.with_oob_state(state); + } + Some(transaction) } else { None } @@ -2284,25 +2312,21 @@ impl FederationV2 { .map_or(false, |x| x.internal); if !internal { - Some(RpcTransaction { - id: op.0.operation_id.fmt_full().to_string(), - created_at: to_unix_time(op.0.creation_time) - .expect("unix time should exist"), - direction: RpcTransactionDirection::Send, - notes, - onchain_state: None, - bitcoin: None, - ln_state: None, - amount: RpcAmount(requested_amount + Amount::from_msats(fedi_fee_msats)), + let mut transaction = RpcTransaction::new( + op.0.operation_id.fmt_full().to_string(), + to_unix_time(op.0.creation_time).expect("unix time should exist"), + RpcAmount(requested_amount + Amount::from_msats(fedi_fee_msats)), + RpcTransactionDirection::Send, fedi_fee_status, - lightning: None, - oob_state: self - .get_client_operation_outcome(op.0.operation_id, op.1) - .await - .map(RpcOOBState::from_spend_v2), - onchain_withdrawal_details: None, - stability_pool_state: None, - }) + tx_date_fiat_info + ) + .with_notes(notes); + + if let Some(outcome) = self.get_client_operation_outcome(op.0.operation_id, op.1).await { + let state = RpcOOBState::from_spend_v2(outcome); + transaction = transaction.with_oob_state(state); + } + Some(transaction) } else { None } @@ -2317,36 +2341,35 @@ impl FederationV2 { .. } => { let outcome = self.get_deposit_outcome(op.0.operation_id).await; - let onchain_state = - RpcOnchainState::from_deposit_state(outcome.clone()); - - Some(RpcTransaction { - id: op.0.operation_id.fmt_full().to_string(), - created_at: to_unix_time(op.0.creation_time) - .expect("unix time should exist"), - direction: RpcTransactionDirection::Receive, - notes, - onchain_state: onchain_state.clone(), - bitcoin: Some(RpcBitcoinDetails { - address: address.assume_checked().to_string(), - }), - ln_state: None, - amount: match outcome { - Some( - DepositStateV2::WaitingForConfirmation { btc_deposited, ..} - | DepositStateV2::Claimed { btc_deposited, ..}, - ) => { - let fees = self.client.get_first_module::().get_fee_consensus().peg_in_abs; - RpcAmount(Amount::from_sats(btc_deposited.to_sat()) - fees) - }, - _ => RpcAmount(Amount::ZERO), + let amount = match outcome { + Some( + DepositStateV2::WaitingForConfirmation { btc_deposited, ..} + | DepositStateV2::Claimed { btc_deposited, ..}, + ) => { + let wallet = self.client.wallet(); + let fees = wallet.map(|w| w.get_fee_consensus().peg_in_abs).unwrap_or(Amount::ZERO); + RpcAmount(Amount::from_sats(btc_deposited.to_sat()) - fees) }, + _ => RpcAmount(Amount::ZERO), + }; + let mut transaction = RpcTransaction::new( + op.0.operation_id.fmt_full().to_string(), + to_unix_time(op.0.creation_time).expect("unix time should exist"), + amount, + RpcTransactionDirection::Receive, fedi_fee_status, - lightning: None, - oob_state: None, - onchain_withdrawal_details: None, - stability_pool_state: None, - }) + tx_date_fiat_info + ) + .with_notes(notes) + .with_bitcoin(RpcBitcoinDetails { + address: address.assume_checked().to_string(), + }); + + if let Some(outcome) = outcome { + let state = RpcOnchainState::from_deposit_state(outcome.clone()); + transaction = transaction.with_onchain_state(state); + } + Some(transaction) } WalletOperationMetaVariant::Withdraw { address, @@ -2365,35 +2388,28 @@ impl FederationV2 { .await .expect("Expected a withdrawal outcome but got None"); - let onchain_state = - RpcOnchainState::from_withdraw_state(Some(outcome)); - let txid_str = match txid { Some(n) => n.to_string(), None => "".to_string(), }; - Some(RpcTransaction { - id: op.0.operation_id.fmt_full().to_string(), - created_at: to_unix_time(op.0.creation_time) - .expect("unix time should exist"), - amount: rpc_amount, + let transaction = RpcTransaction::new( + op.0.operation_id.fmt_full().to_string(), + to_unix_time(op.0.creation_time).expect("unix time should exist"), + rpc_amount, + RpcTransactionDirection::Send, fedi_fee_status, - direction: RpcTransactionDirection::Send, - notes, - onchain_state, - bitcoin: None, - ln_state: None, - lightning: None, - oob_state: None, - onchain_withdrawal_details: Some(WithdrawalDetails { - address: address.assume_checked().to_string(), - txid: txid_str, - fee: RpcAmount(Amount::from_sats(fee.amount().to_sat())), - fee_rate: fee.fee_rate.sats_per_kvb, - }), - stability_pool_state: None, - }) + tx_date_fiat_info + ) + .with_notes(notes) + .with_onchain_state(RpcOnchainState::from_withdraw_state(outcome)) + .with_onchain_withdrawal_details(WithdrawalDetails { + address: address.assume_checked().to_string(), + txid: txid_str, + fee: RpcAmount(Amount::from_sats(fee.amount().to_sat())), + fee_rate: fee.fee_rate.sats_per_kvb, + }); + Some(transaction) } WalletOperationMetaVariant::RbfWithdraw { rbf: _, change: _ } => None, } @@ -2425,19 +2441,6 @@ impl FederationV2 { dbtx.commit_tx_result().await } - // Database - - pub async fn get_xmpp_username(&self) -> Option { - self.dbtx().await.get_value(&XmppUsernameKey).await - } - - pub async fn save_xmpp_username(&self, username: &str) -> Result<()> { - let mut dbtx = self.dbtx().await; - dbtx.insert_entry(&XmppUsernameKey, &username.to_owned()) - .await; - dbtx.commit_tx_result().await - } - // FIXME this is busted in social recovery pub async fn get_invite_code(&self) -> String { self.dbtx() @@ -2480,7 +2483,7 @@ impl FederationV2 { force_update: bool, ) -> Result { self.client - .get_first_module::() + .sp()? .account_info(force_update) .await .context("Error when fetching account info") @@ -2488,7 +2491,7 @@ impl FederationV2 { pub async fn stability_pool_next_cycle_start_time(&self) -> Result { self.client - .get_first_module::() + .sp()? .next_cycle_start_time() .await .context("Error when fetching next cycle start time") @@ -2496,7 +2499,7 @@ impl FederationV2 { pub async fn stability_pool_cycle_start_price(&self) -> Result { self.client - .get_first_module::() + .sp()? .cycle_start_price() .await .context("Error when fetching cycle start price") @@ -2504,7 +2507,7 @@ impl FederationV2 { pub async fn stability_pool_average_fee_rate(&self, num_cycles: u64) -> Result { self.client - .get_first_module::() + .sp()? .average_fee_rate(num_cycles) .await .context("Error when fetching average fee rate") @@ -2513,7 +2516,7 @@ impl FederationV2 { pub async fn stability_pool_available_liquidity(&self) -> Result { let stats = self .client - .get_first_module::() + .sp()? .liquidity_stats() .await .context("Error when fetching liquidity stats")?; @@ -2545,10 +2548,13 @@ impl FederationV2 { ))); } - let module = self.client.get_first_module::(); + let module = self.client.sp()?; let operation_id = module.deposit_to_seek(amount).await?; self.write_pending_send_fedi_fee(operation_id, fedi_fee) .await?; + let _ = self + .record_tx_date_fiat_info(operation_id, amount + fedi_fee) + .await; drop(spend_guard); if let Ok(index) = module.current_cycle_index().await { @@ -2606,7 +2612,7 @@ impl FederationV2 { .await?; let (operation_id, _) = self .client - .get_first_module::() + .sp()? .withdraw(unlocked_amount, locked_bps) .await?; self.write_pending_receive_fedi_fee_ppm(operation_id, fedi_fee_ppm) @@ -2621,9 +2627,11 @@ impl FederationV2 { } async fn subscribe_stability_pool_deposit_to_seek(&self, operation_id: OperationId) { - let update_stream = self - .client - .get_first_module::() + let Ok(stability_pool) = self.client.sp() else { + return; + }; + + let update_stream = stability_pool .subscribe_deposit_operation(operation_id) .await; if let Ok(update_stream) = update_stream { @@ -2654,11 +2662,11 @@ impl FederationV2 { } async fn subscribe_stability_pool_withdraw(&self, operation_id: OperationId) { - let update_stream = self - .client - .get_first_module::() - .subscribe_withdraw(operation_id) - .await; + let Ok(stability_pool) = self.client.sp() else { + return; + }; + + let update_stream = stability_pool.subscribe_withdraw(operation_id).await; if let Ok(update_stream) = update_stream { let mut updates = update_stream.into_stream(); while let Some(state) = updates.next().await { @@ -2669,6 +2677,7 @@ impl FederationV2 { let _ = self .write_success_receive_fedi_fee(operation_id, amount) .await; + let _ = self.record_tx_date_fiat_info(operation_id, amount).await; } StabilityPoolWithdrawalOperationState::InvalidOperationType | StabilityPoolWithdrawalOperationState::TxRejected(_) @@ -3158,6 +3167,51 @@ impl FederationV2 { res } + + #[instrument(skip(self), fields(fiat_code, fiat_value_hundredths), err, ret)] + async fn record_tx_date_fiat_info( + &self, + operation_id: OperationId, + amount: Amount, + ) -> anyhow::Result<()> { + // Early return if we've already recorded the fiat info for given operation + let mut dbtx = self.dbtx().await; + let op_db_key = TransactionDateFiatInfoKey(operation_id); + if dbtx.get_value(&op_db_key).await.is_some() { + info!("Transaction date fiat info already exists for given OP ID, not overwriting"); + return Ok(()); + } + + let Some(cached_fiat_fx_info) = self.app_state.get_cached_fiat_fx_info().await else { + bail!("No cached fiat FX info present"); + }; + let fiat_code = cached_fiat_fx_info.fiat_code.clone(); + tracing::Span::current().record("fiat_code", &fiat_code); + + // Using cents below for readability + // 1 BTC = (btc_to_fiat_hundredths) cents + // => 10^8 sats = (btc_to_fiat_hundredths) cents + // => 10^11 msats = (btc_to_fiat_hundredths) cents + // => 1 msat = (btc_to_fiat_hundredths) / 10^11 cents + // => amount msat = amount * (btc_to_fiat_hundredths) / 10^11 cents + let fiat_value_hundredths = + (amount.msats * cached_fiat_fx_info.btc_to_fiat_hundredths) / (SATS_PER_BITCOIN * 1000); + tracing::Span::current().record("fiat_value_hundredths", fiat_value_hundredths); + dbtx.insert_entry( + &TransactionDateFiatInfoKey(operation_id), + &TransactionDateFiatInfo { + fiat_code, + fiat_value_hundredths, + }, + ) + .await; + match dbtx.commit_tx_result().await { + Ok(_) => info!("Successfully logged transaction date fiat info"), + Err(e) => error!(?e, "Error logging transaction date fiat info"), + }; + + Ok(()) + } } #[inline(always)] diff --git a/bridge/fedi-ffi/src/fedi_fee.rs b/bridge/fedi-ffi/src/fedi_fee.rs index bd1d5fe..a1c6485 100644 --- a/bridge/fedi-ffi/src/fedi_fee.rs +++ b/bridge/fedi-ffi/src/fedi_fee.rs @@ -8,7 +8,7 @@ use fedimint_core::core::ModuleKind; use fedimint_core::db::IDatabaseTransactionOpsCoreTyped; use fedimint_core::task::TaskGroup; use fedimint_core::Amount; -use fedimint_ln_client::{LightningClientModule, OutgoingLightningPayment}; +use fedimint_ln_client::OutgoingLightningPayment; use futures::StreamExt; use lightning_invoice::Bolt11Invoice; use tokio::sync::{Mutex, OwnedMutexGuard}; @@ -16,6 +16,7 @@ use tracing::{error, info, instrument, warn}; use crate::api::IFediApi; use crate::constants::MILLION; +use crate::federation_v2::client::ClientExt; use crate::federation_v2::db::{ OutstandingFediFeesPerTXTypeKey, OutstandingFediFeesPerTXTypeKeyPrefix, }; @@ -352,7 +353,7 @@ impl FediFeeRemittanceService { let extra_meta = LightningSendMetadata { is_fedi_fee_remittance: true, }; - let ln = fed.client.get_first_module::(); + let ln = fed.client.ln()?; let OutgoingLightningPayment { payment_type, .. } = ln .pay_bolt11_invoice(gateway, invoice.to_owned(), extra_meta.clone()) .await?; diff --git a/bridge/fedi-ffi/src/lib.rs b/bridge/fedi-ffi/src/lib.rs index 95a856c..7369a84 100644 --- a/bridge/fedi-ffi/src/lib.rs +++ b/bridge/fedi-ffi/src/lib.rs @@ -5,6 +5,7 @@ pub mod federation_v2; // FIXME: kinda feels like this should just be it's own crate ... pub mod constants; pub mod device_registration; +pub mod envs; pub mod error; pub mod event; pub mod features; diff --git a/bridge/fedi-ffi/src/logging.rs b/bridge/fedi-ffi/src/logging.rs index 2458f8c..4e5e9d8 100644 --- a/bridge/fedi-ffi/src/logging.rs +++ b/bridge/fedi-ffi/src/logging.rs @@ -20,6 +20,10 @@ use tracing_subscriber::{EnvFilter, Layer}; use super::event::{Event, EventSink, TypedEventExt}; +pub fn default_log_filter() -> String { + format!("info,{LOG_CLIENT}=debug,fediffi=trace,{LOG_CLIENT_REACTOR}=trace,{LOG_CLIENT_MODULE_WALLET}=trace") +} + pub struct ReactNativeLayer(pub EventSink); impl Layer for ReactNativeLayer @@ -70,9 +74,7 @@ pub fn init_logging( .with_filter(EnvFilter::from_str(log_filter).unwrap_or_default()), ); - let reg = reg.with(log_file_layer.with_filter(EnvFilter::new( - format!("info,{LOG_CLIENT}=debug,fediffi=trace,{LOG_CLIENT_REACTOR}=trace,{LOG_CLIENT_MODULE_WALLET}=trace"), - ))); + let reg = reg.with(log_file_layer.with_filter(EnvFilter::new(default_log_filter()))); let res = if cfg!(target_os = "android") && option_env!("FEDI_DEV_LOGS").is_some() { let time = fedimint_core::time::now() diff --git a/bridge/fedi-ffi/src/matrix.rs b/bridge/fedi-ffi/src/matrix.rs index c573a22..c869161 100644 --- a/bridge/fedi-ffi/src/matrix.rs +++ b/bridge/fedi-ffi/src/matrix.rs @@ -1,15 +1,16 @@ -use std::collections::HashMap; use std::path::Path; -use std::sync::atomic::AtomicU64; use std::sync::Arc; use std::time::Duration; use anyhow::{bail, Context, Result}; -use eyeball::Subscriber; -use fedimint_core::task::{MaybeSend, MaybeSync, TaskGroup}; +use fedimint_core::task::TaskGroup; use fedimint_derive_secret::DerivableSecret; -use futures::{Future, StreamExt}; +use futures::StreamExt; +use matrix_sdk::attachment::{ + AttachmentConfig, AttachmentInfo, BaseFileInfo, BaseImageInfo, BaseVideoInfo, +}; use matrix_sdk::encryption::BackupDownloadStrategy; +use matrix_sdk::media::{MediaFormat, MediaRequest}; use matrix_sdk::notification_settings::NotificationSettings; pub use matrix_sdk::ruma::api::client::account::register::v3 as register; use matrix_sdk::ruma::api::client::directory::get_public_rooms_filtered::v3 as get_public_rooms_filtered; @@ -22,26 +23,34 @@ use matrix_sdk::ruma::api::client::room::Visibility; use matrix_sdk::ruma::api::client::state::send_state_event; use matrix_sdk::ruma::api::client::uiaa; use matrix_sdk::ruma::directory::PublicRoomsChunk; +use matrix_sdk::ruma::events::message::TextContentBlock; +use matrix_sdk::ruma::events::poll::end::PollEndEventContent; +use matrix_sdk::ruma::events::poll::response::{PollResponseEventContent, SelectionsContentBlock}; +use matrix_sdk::ruma::events::poll::start::{ + PollAnswer, PollAnswers, PollContentBlock, PollStartEventContent, +}; use matrix_sdk::ruma::events::receipt::ReceiptThread; use matrix_sdk::ruma::events::room::encryption::RoomEncryptionEventContent; -use matrix_sdk::ruma::events::room::message::{MessageType, RoomMessageEventContent}; +use matrix_sdk::ruma::events::room::message::{ + MessageType, RoomMessageEventContent, RoomMessageEventContentWithoutRelation, +}; use matrix_sdk::ruma::events::room::power_levels::RoomPowerLevelsEventContent; +use matrix_sdk::ruma::events::room::MediaSource; use matrix_sdk::ruma::events::{AnySyncTimelineEvent, InitialStateEvent}; -use matrix_sdk::ruma::{assign, OwnedMxcUri, RoomId, UserId}; +use matrix_sdk::ruma::{assign, EventId, OwnedMxcUri, RoomId, UInt, UserId}; use matrix_sdk::sliding_sync::Ranges; use matrix_sdk::{Client, RoomInfo, RoomMemberships}; use matrix_sdk_ui::sync_service::{self, SyncService}; use matrix_sdk_ui::timeline::default_event_filter; use matrix_sdk_ui::{room_list_service, RoomListService}; use mime::Mime; -use serde::Serialize; -use tokio::sync::Mutex; use tracing::{error, info, warn}; use crate::error::ErrorCode; -use crate::event::{EventSink, TypedEventExt}; -use crate::observable::{Observable, ObservableUpdate, ObservableVec, ObservableVecUpdate}; +use crate::event::EventSink; +use crate::observable::{Observable, ObservablePool, ObservableVec}; use crate::storage::AppState; +use crate::types::RpcMediaUploadParams; mod types; pub use types::*; @@ -54,11 +63,9 @@ pub struct Matrix { sync_service: Arc, /// manages list of room visible to user. room_list_service: Arc, - event_sink: EventSink, task_group: TaskGroup, notification_settings: NotificationSettings, - /// list of active observables - observables: Arc>>, + observable_pool: ObservablePool, } impl Matrix { @@ -130,9 +137,8 @@ impl Matrix { client, room_list_service: sync_service.room_list_service(), sync_service: Arc::new(sync_service), - event_sink, + observable_pool: ObservablePool::new(event_sink, task_group.clone()), task_group, - observables: Default::default(), }; let encryption_passphrase = Self::encryption_passphrase(matrix_secret); matrix @@ -320,72 +326,8 @@ impl Matrix { }) } - /// make observable with `initial` value and run `func` in a task group. - /// `func` can send observable updates. - pub async fn make_observable( - &self, - initial: T, - func: impl FnOnce(Self, u64) -> Fut + MaybeSend + 'static, - ) -> Result> - where - T: 'static, - Fut: Future> + MaybeSend + MaybeSync + 'static, - { - static OBSERVABLE_ID: AtomicU64 = AtomicU64::new(0); - let id = OBSERVABLE_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let observable = Observable::new(id, initial); - let tg = self.task_group.make_subgroup(); - { - let mut observables = self.observables.lock().await; - observables.insert(id, tg.clone()); - // should be independent of number of rooms and number of messages - const OBSERVABLE_WARN_LIMIT: usize = 20; - let observable_counts = observables.len(); - if OBSERVABLE_WARN_LIMIT < observable_counts { - warn!(%observable_counts, "frontend is using too many observabes, likely forgot to unsubscribe"); - } - }; - let this = self.clone(); - tg.spawn_cancellable( - format!("observable type={}", std::any::type_name::()), - func(this, id), - ); - Ok(observable) - } - - /// Convert eyeball::Subscriber to rpc Observable type. - pub async fn make_observable_from_subscriber( - &self, - mut sub: Subscriber, - ) -> Result> - where - T: std::fmt::Debug + Clone + Serialize + MaybeSend + MaybeSync + 'static, - { - self.make_observable(sub.get(), move |this, id| async move { - let mut update_index = 0; - while let Some(value) = sub.next().await { - this.send_observable_update(ObservableUpdate::new(id, update_index, value)) - .await; - update_index += 1; - } - Ok(()) - }) - .await - } - pub async fn observable_cancel(&self, id: u64) -> Result<()> { - let Some(tg) = self.observables.lock().await.remove(&id) else { - bail!(ErrorCode::UnknownObservable); - }; - tg.shutdown_join_all(None).await?; - Ok(()) - } - - pub async fn send_observable_update( - &self, - event: ObservableUpdate, - ) { - self.event_sink.observable_update(event); + self.observable_pool.observable_cancel(id).await } /// All chats in matrix are rooms, whether DM or group chats. @@ -398,29 +340,10 @@ impl Matrix { &self, list: room_list_service::RoomList, ) -> Result>> { - let (initial, mut stream) = list.entries(); - self.make_observable( - initial.into_iter().map(RpcRoomListEntry::from).collect(), - move |this, id| async move { - let mut update_index = 0; - while let Some(diffs) = stream.next().await { - this.send_observable_update( - ObservableVecUpdate::::new_diffs( - id, - update_index, - diffs - .into_iter() - .map(|x| x.map(RpcRoomListEntry::from)) - .collect(), - ), - ) - .await; - update_index += 1; - } - Ok(()) - }, - ) - .await + let (initial, stream) = list.entries(); + self.observable_pool + .make_observable_from_vec_diff_stream(initial, stream) + .await } pub async fn room_list_update_ranges(&self, ranges: Ranges) -> Result<()> { @@ -435,41 +358,27 @@ impl Matrix { /// /// We delay the events by 2 seconds to avoid flickering. pub async fn observe_sync_status(&self) -> Result> { - let mut stream = Box::pin( - self.room_list_service - .sync_indicator(Duration::from_secs(2), Duration::from_secs(2)), - ); - // first item is emitted immediately - self.make_observable( - stream - .next() - .await - .map(|x| x.into()) - .context("first element not found in stream")?, - |this, id| async move { - let mut index = 0; - while let Some(item) = stream.next().await { - info!("matrix sync status: {item:?}"); - this.send_observable_update(ObservableUpdate::new( - id, - index, - RpcSyncIndicator::from(item), - )) - .await; - index += 1; - } - Ok(()) - }, - ) - .await + self.observable_pool + .make_observable_from_stream( + None, + self.room_list_service + .sync_indicator(Duration::from_secs(2), Duration::from_secs(2)), + ) + .await } - async fn room(&self, room_id: &RoomId) -> Result { - Ok(self.room_list_service.room(room_id)?) + async fn room( + &self, + room_id: &RoomId, + ) -> Result { + self.room_list_service.room(room_id) } /// See [`matrix_sdk_ui::Timeline`]. - async fn timeline(&self, room_id: &RoomId) -> Result> { + async fn timeline( + &self, + room_id: &RoomId, + ) -> Result, room_list_service::Error> { let room = self.room(room_id).await?; if !room.is_timeline_initialized() { room.init_timeline_with_builder( @@ -497,30 +406,10 @@ impl Matrix { room_id: &RoomId, ) -> Result> { let timeline = self.timeline(room_id).await?; - let (initial, mut stream) = timeline.subscribe_batched().await; - self.make_observable( - initial - .into_iter() - .map(RpcTimelineItem::from_timeline_item) - .collect(), - move |this, id| async move { - let mut update_index = 0; - while let Some(diffs) = stream.next().await { - this.send_observable_update(ObservableVecUpdate::new_diffs( - id, - update_index, - diffs - .into_iter() - .map(|x| x.map(RpcTimelineItem::from_timeline_item)) - .collect(), - )) - .await; - update_index += 1; - } - Ok(()) - }, - ) - .await + let (initial, stream) = timeline.subscribe_batched().await; + self.observable_pool + .make_observable_from_vec_diff_stream(initial, stream) + .await } pub async fn room_timeline_items_paginate_backwards( @@ -542,24 +431,9 @@ impl Matrix { .live_back_pagination_status() .await .context("we only have live rooms")?; - self.make_observable( - RpcBackPaginationStatus::from(current), - move |this, id| async move { - let mut stream = std::pin::pin!(stream); - let mut update_index = 0; - while let Some(value) = stream.next().await { - this.send_observable_update(ObservableUpdate::new( - id, - update_index, - RpcBackPaginationStatus::from(value), - )) - .await; - update_index += 1; - } - Ok(()) - }, - ) - .await + self.observable_pool + .make_observable_from_stream(Some(current), stream) + .await } pub async fn send_message_text(&self, room_id: &RoomId, message: String) -> anyhow::Result<()> { @@ -593,7 +467,7 @@ impl Matrix { pub async fn wait_for_room_id(&self, room_id: &RoomId) -> Result<()> { fedimint_core::task::timeout(Duration::from_secs(20), async { loop { - match self.room_list_service.room(room_id) { + match self.timeline(room_id).await { Ok(_) => return Ok(()), Err(room_list_service::Error::RoomNotFound(_)) => { fedimint_core::task::sleep(Duration::from_millis(100)).await; @@ -654,7 +528,9 @@ impl Matrix { pub async fn room_observe_info(&self, room_id: &RoomId) -> Result> { let sub = self.room(room_id).await?.inner_room().subscribe_info(); - self.make_observable_from_subscriber(sub).await + self.observable_pool + .make_observable_from_subscriber(sub) + .await } pub async fn room_invite_user_by_id(&self, room_id: &RoomId, user_id: &UserId) -> Result<()> { @@ -901,6 +777,154 @@ impl Matrix { .filter_map(RpcTimelineItem::from_preview_item) .collect()) } + + pub async fn send_attachment( + &self, + room_id: &RoomId, + filename: String, + params: RpcMediaUploadParams, + data: Vec, + ) -> Result<()> { + let mime = params + .mime_type + .parse::() + .context(ErrorCode::BadRequest)?; + + let size = Some(UInt::from(data.len() as u32)); + + let width = params.width.map(UInt::from); + + let height = params.height.map(UInt::from); + + let info = match mime.type_() { + mime::IMAGE => AttachmentInfo::Image(BaseImageInfo { + width, + height, + size, + blurhash: None, + }), + mime::VIDEO => AttachmentInfo::Video(BaseVideoInfo { + width, + height, + size, + duration: None, + blurhash: None, + }), + _ => AttachmentInfo::File(BaseFileInfo { size }), + }; + + let config = AttachmentConfig::default().info(info); + + self.room(room_id) + .await? + .send_attachment(&filename, &mime, data, config) + .await?; + Ok(()) + } + + pub async fn edit_message( + &self, + room_id: &RoomId, + event_id: &EventId, + new_content: String, + ) -> Result<()> { + let timeline = self.timeline(room_id).await?; + let edit_info = timeline + .edit_info_from_event_id(event_id) + .await + .context("failed to get edit info")?; + + let new_content = RoomMessageEventContentWithoutRelation::text_plain(new_content); + + timeline.edit(new_content, edit_info).await?; + + Ok(()) + } + + pub async fn delete_message( + &self, + room_id: &RoomId, + event_id: &EventId, + reason: Option, + ) -> Result<()> { + let timeline = self.timeline(room_id).await?; + let event = timeline + .item_by_event_id(event_id) + .await + .ok_or_else(|| anyhow::anyhow!("Event not found"))?; + + timeline.redact(&event, reason.as_deref()).await?; + + Ok(()) + } + + pub async fn download_file(&self, source: MediaSource) -> Result> { + let request = MediaRequest { + source, + format: MediaFormat::File, + }; + + let content = self + .client + .media() + .get_media_content(&request, false) + .await?; + Ok(content) + } + + pub async fn start_poll( + &self, + room_id: &RoomId, + question: String, + answers: Vec, + ) -> Result<()> { + let timeline = self.timeline(room_id).await?; + + let poll_answers: PollAnswers = answers + .into_iter() + .enumerate() + .map(|(i, text)| PollAnswer::new(i.to_string(), TextContentBlock::plain(text))) + .collect::>() + .try_into() + .map_err(|_| anyhow::anyhow!("Invalid number of poll answers"))?; + + let poll_content = + PollContentBlock::new(TextContentBlock::plain(question.clone()), poll_answers); + + let content = PollStartEventContent::new( + TextContentBlock::plain(format!("Poll: {}", question)), + poll_content, + ); + + timeline.send(content.into()).await?; + Ok(()) + } + + pub async fn end_poll(&self, room_id: &RoomId, poll_start_id: &EventId) -> Result<()> { + let timeline = self.timeline(room_id).await?; + + let content = + PollEndEventContent::with_plain_text("This poll has ended", poll_start_id.to_owned()); + + timeline.send(content.into()).await?; + Ok(()) + } + + pub async fn respond_to_poll( + &self, + room_id: &RoomId, + poll_start_id: &EventId, + selections: Vec, + ) -> Result<()> { + let timeline = self.timeline(room_id).await?; + + let selections_content = SelectionsContentBlock::from(selections); + + let content = PollResponseEventContent::new(selections_content, poll_start_id.to_owned()); + + timeline.send(content.into()).await?; + Ok(()) + } } #[cfg(test)] @@ -919,8 +943,8 @@ mod tests { use crate::event::IEventSink; use crate::ffi::PathBasedStorage; - const TEST_HOME_SERVER: &str = "matrix-synapse-homeserver2.dev.fedibtc.com"; - const TEST_SLIDING_SYNC: &str = "https://sliding.matrix-synapse-homeserver2.dev.fedibtc.com"; + const TEST_HOME_SERVER: &str = "staging.m1.8fa.in"; + const TEST_SLIDING_SYNC: &str = "https://staging.sliding.m1.8fa.in"; async fn mk_matrix_login( user_name: &str, @@ -1089,4 +1113,58 @@ mod tests { ); info!("password: {password}"); } + + #[ignore] + #[tokio::test(flavor = "multi_thread")] + async fn test_send_and_download_attachment() -> Result<()> { + TracingSetup::default().init().unwrap(); + let (matrix, _event_rx, _temp_dir) = mk_matrix_new_user().await?; + let (matrix2, _event_rx, _temp_dir) = mk_matrix_new_user().await?; + + // Create a room + let room_id = matrix + .create_or_get_dm(matrix2.client.user_id().unwrap()) + .await?; + + // Prepare attachment data + let filename = "test.txt".to_string(); + let mime_type = "text/plain".to_string(); + let data = b"Hello, World!".to_vec(); + + // Send attachment + matrix + .send_attachment( + &room_id, + filename.clone(), + RpcMediaUploadParams { + mime_type, + width: None, + height: None, + }, + data.clone(), + ) + .await?; + fedimint_core::task::sleep(Duration::from_millis(100)).await; + + let timeline = matrix.timeline(&room_id).await?; + let event = timeline + .latest_event() + .await + .context("expected last event")?; + let source = match event.content().as_message().unwrap().msgtype() { + MessageType::File(f) => f.source.clone(), + _ => unreachable!(), + }; + + // Download the file + let downloaded_data = matrix.download_file(source).await?; + + // Assert that the downloaded data matches the original data + assert_eq!( + downloaded_data, data, + "Downloaded data does not match original data" + ); + + Ok(()) + } } diff --git a/bridge/fedi-ffi/src/matrix/types.rs b/bridge/fedi-ffi/src/matrix/types.rs index 0e82928..3d3a825 100644 --- a/bridge/fedi-ffi/src/matrix/types.rs +++ b/bridge/fedi-ffi/src/matrix/types.rs @@ -148,8 +148,8 @@ impl From for RpcBackPaginationStatus { } } -impl RpcTimelineItem { - pub fn from_timeline_item(item: Arc) -> Self { +impl From> for RpcTimelineItem { + fn from(item: Arc) -> Self { match **item { TimelineItemKind::Event(ref e) => { let content = if let Some(json) = e.latest_json() { @@ -198,7 +198,9 @@ impl RpcTimelineItem { }, } } +} +impl RpcTimelineItem { pub fn unknown() -> Self { warn!("unknown timeline item"); Self::Unknown diff --git a/bridge/fedi-ffi/src/observable.rs b/bridge/fedi-ffi/src/observable.rs index 3265d09..90057fd 100644 --- a/bridge/fedi-ffi/src/observable.rs +++ b/bridge/fedi-ffi/src/observable.rs @@ -1,7 +1,19 @@ +use std::collections::HashMap; +use std::sync::atomic::AtomicU64; +use std::sync::Arc; + +use anyhow::{bail, Context, Result}; +use eyeball::Subscriber; use eyeball_im::VectorDiff; +use fedimint_core::task::{MaybeSend, MaybeSync, TaskGroup}; +use futures::{Future, Stream, StreamExt}; use imbl::Vector; use serde::Serialize; +use tokio::sync::Mutex; +use tracing::warn; +use crate::error::ErrorCode; +use crate::event::{EventSink, TypedEventExt}; use crate::serde::{SerdeAs, SerdeVectorDiff}; /// ObservableVec is special; it utilizes VectorDiff for efficient @@ -91,3 +103,164 @@ impl ObservableVecUpdate { Self::new(id, update_index, SerdeAs::new(diffs)) } } + +/// ObservablePool provides the necessary functionality to make observables, +/// send updates, and to cancel observables. By embedding an instance of +/// ObservablePool, any struct can leverage the power of observables without +/// needing to deal with implementation complexity. +#[derive(Clone)] +pub struct ObservablePool { + event_sink: EventSink, + task_group: TaskGroup, + /// list of active observables + observables: Arc>>, +} + +impl ObservablePool { + pub fn new(event_sink: EventSink, task_group: TaskGroup) -> Self { + Self { + event_sink, + task_group, + observables: Default::default(), + } + } + + /// make observable with `initial` value and run `func` in a task group. + /// `func` can send observable updates. + pub async fn make_observable( + &self, + initial: T, + func: impl FnOnce(Self, u64) -> Fut + MaybeSend + 'static, + ) -> Result> + where + T: 'static, + Fut: Future> + MaybeSend + MaybeSync + 'static, + { + static OBSERVABLE_ID: AtomicU64 = AtomicU64::new(0); + let id = OBSERVABLE_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let observable = Observable::new(id, initial); + let tg = self.task_group.make_subgroup(); + { + let mut observables = self.observables.lock().await; + observables.insert(id, tg.clone()); + // should be independent of number of rooms and number of messages + const OBSERVABLE_WARN_LIMIT: usize = 20; + let observable_counts = observables.len(); + if OBSERVABLE_WARN_LIMIT < observable_counts { + warn!(%observable_counts, "frontend is using too many observabes, likely forgot to unsubscribe"); + } + }; + let this = self.clone(); + tg.spawn_cancellable( + format!("observable type={}", std::any::type_name::()), + func(this, id), + ); + Ok(observable) + } + + /// Helper function to make an observable using a stream. Typically T will + /// be the type used within Rust code and R will be the corresponding RPC + /// type. If an initial value is not provided, attempts to get the first + /// item from the stream. + pub async fn make_observable_from_stream( + &self, + initial: Option, + stream: impl Stream + MaybeSync + MaybeSend + 'static, + ) -> Result> + where + T: std::fmt::Debug + MaybeSend + MaybeSync + 'static, + R: 'static + Clone + Serialize + std::fmt::Debug + MaybeSend + MaybeSync + From, + { + let mut stream = Box::pin(stream); + let initial = if let Some(initial) = initial { + initial + } else { + stream + .next() + .await + .context("first element not found in stream")? + }; + self.make_observable(R::from(initial), move |this, id| async move { + let mut update_index = 0; + while let Some(value) = stream.next().await { + this.send_observable_update(ObservableUpdate::new( + id, + update_index, + R::from(value), + )) + .await; + update_index += 1; + } + Ok(()) + }) + .await + } + + /// Helper function to make an observable using a stream of vector diffs. + /// Typically T will be the type used within Rust code and R will be the + /// corresponding RPC type. If an initial is not provided, attempts to + /// get the first item from the stream. + pub async fn make_observable_from_vec_diff_stream( + &self, + initial: Vector, + stream: impl Stream>> + MaybeSync + MaybeSend + 'static, + ) -> Result> + where + T: std::fmt::Debug + Clone + MaybeSend + MaybeSync + 'static, + R: 'static + Clone + Serialize + std::fmt::Debug + MaybeSend + MaybeSync + From, + { + self.make_observable( + initial.into_iter().map(|t| R::from(t)).collect(), + move |this, id| async move { + let mut update_index = 0; + let mut stream = std::pin::pin!(stream); + while let Some(diffs) = stream.next().await { + this.send_observable_update(ObservableVecUpdate::new_diffs( + id, + update_index, + diffs.into_iter().map(|diff| diff.map(R::from)).collect(), + )) + .await; + update_index += 1; + } + Ok(()) + }, + ) + .await + } + + /// Convert eyeball::Subscriber to rpc Observable type. + pub async fn make_observable_from_subscriber( + &self, + mut sub: Subscriber, + ) -> Result> + where + T: std::fmt::Debug + Clone + Serialize + MaybeSend + MaybeSync + 'static, + { + self.make_observable(sub.get(), move |this, id| async move { + let mut update_index = 0; + while let Some(value) = sub.next().await { + this.send_observable_update(ObservableUpdate::new(id, update_index, value)) + .await; + update_index += 1; + } + Ok(()) + }) + .await + } + + pub async fn observable_cancel(&self, id: u64) -> Result<()> { + let Some(tg) = self.observables.lock().await.remove(&id) else { + bail!(ErrorCode::UnknownObservable); + }; + tg.shutdown_join_all(None).await?; + Ok(()) + } + + pub async fn send_observable_update( + &self, + event: ObservableUpdate, + ) { + self.event_sink.observable_update(event); + } +} diff --git a/bridge/fedi-ffi/src/rpc.rs b/bridge/fedi-ffi/src/rpc.rs index 5eeb0d1..2a1cd91 100644 --- a/bridge/fedi-ffi/src/rpc.rs +++ b/bridge/fedi-ffi/src/rpc.rs @@ -18,6 +18,8 @@ use matrix_sdk::ruma::api::client::profile::get_profile; use matrix_sdk::ruma::api::client::push::Pusher; use matrix_sdk::ruma::directory::PublicRoomsChunk; use matrix_sdk::ruma::events::room::power_levels::RoomPowerLevelsEventContent; +use matrix_sdk::ruma::events::room::MediaSource; +use matrix_sdk::ruma::OwnedEventId; use matrix_sdk::sliding_sync::Ranges; use matrix_sdk::RoomInfo; use mime::Mime; @@ -33,7 +35,7 @@ use super::storage::Storage; use super::types::{ RpcAmount, RpcFederation, RpcFederationId, RpcInvoice, RpcOperationId, RpcPayInvoiceResponse, RpcPeerId, RpcPublicKey, RpcRecoveryId, RpcSignedLnurlMessage, RpcStabilityPoolAccountInfo, - RpcTransaction, RpcXmppCredentials, SocialRecoveryQr, + RpcTransaction, SocialRecoveryQr, }; use crate::api::IFediApi; use crate::constants::{GLOBAL_MATRIX_SERVER, GLOBAL_MATRIX_SLIDING_SYNC_PROXY}; @@ -47,11 +49,12 @@ use crate::matrix::{ RpcRoomNotificationMode, RpcSyncIndicator, RpcTimelineItem, RpcUserId, }; use crate::observable::{Observable, ObservableVec}; +use crate::storage::FiatFXInfo; use crate::types::{ GuardianStatus, RpcBridgeStatus, RpcCommunity, RpcDeviceIndexAssignmentStatus, RpcEcashInfo, - RpcFederationPreview, RpcFeeDetails, RpcGenerateEcashResponse, RpcLightningGateway, - RpcNostrPubkey, RpcNostrSecret, RpcPayAddressResponse, RpcRegisteredDevice, - RpcTransactionDirection, + RpcFederationMaybeLoading, RpcFederationPreview, RpcFeeDetails, RpcGenerateEcashResponse, + RpcLightningGateway, RpcMediaUploadParams, RpcNostrPubkey, RpcNostrSecret, + RpcPayAddressResponse, RpcRegisteredDevice, RpcTransactionDirection, }; #[derive(Debug, thiserror::Error)] @@ -222,7 +225,7 @@ async fn federationPreview( } #[macro_rules_derive(rpc_method!)] -async fn listFederations(bridge: Arc) -> anyhow::Result> { +async fn listFederations(bridge: Arc) -> anyhow::Result> { Ok(bridge.list_federations().await) } @@ -341,6 +344,20 @@ async fn validateEcash(_bridge: Arc, ecash: String) -> anyhow::Result, + fiat_code: String, + btc_to_fiat_hundredths: u64, +) -> anyhow::Result<()> { + bridge + .update_cached_fiat_fx_info(FiatFXInfo { + fiat_code, + btc_to_fiat_hundredths, + }) + .await +} + #[macro_rules_derive(federation_rpc_method!)] async fn listTransactions( federation: Arc, @@ -484,16 +501,8 @@ async fn signLnurlMessage( } #[macro_rules_derive(federation_rpc_method!)] -async fn xmppCredentials(federation: Arc) -> anyhow::Result { - Ok(federation.get_xmpp_credentials().await) -} - -#[macro_rules_derive(federation_rpc_method!)] -async fn backupXmppUsername(federation: Arc, username: String) -> anyhow::Result<()> { - federation.save_xmpp_username(&username).await?; - if !federation.recovering() { - federation.backup().await?; - } +async fn backupNow(federation: Arc) -> anyhow::Result<()> { + federation.backup().await?; Ok(()) } @@ -866,6 +875,29 @@ async fn matrixRoomPreviewContent( matrix.preview_room_content(&room_id.into_typed()?).await } +#[macro_rules_derive(rpc_method!)] +async fn matrixSendAttachment( + bridge: Arc, + room_id: RpcRoomId, + filename: String, + file_path: PathBuf, + params: RpcMediaUploadParams, +) -> anyhow::Result<()> { + let matrix = get_matrix(&bridge).await?; + + let file_data = bridge + .storage + .read_file(&file_path) + .await? + .ok_or_else(|| anyhow::anyhow!("File not found"))?; + + matrix + .send_attachment(&room_id.into_typed()?, filename, params, file_data) + .await?; + + Ok(()) +} + #[macro_rules_derive(rpc_method!)] async fn matrixRoomTimelineItemsPaginateBackwards( bridge: Arc, @@ -1245,6 +1277,93 @@ async fn matrixSetPusher(bridge: Arc, pusher: RpcPusher) -> anyhow::Resu let matrix = get_matrix(&bridge).await?; matrix.set_pusher(pusher.0).await } + +#[macro_rules_derive(rpc_method!)] +async fn matrixEditMessage( + bridge: Arc, + room_id: RpcRoomId, + event_id: String, + new_content: String, +) -> anyhow::Result<()> { + let matrix = get_matrix(&bridge).await?; + matrix + .edit_message( + &room_id.into_typed()?, + &event_id.parse::()?, + new_content, + ) + .await +} + +#[macro_rules_derive(rpc_method!)] +async fn matrixDeleteMessage( + bridge: Arc, + room_id: RpcRoomId, + event_id: String, + reason: Option, +) -> anyhow::Result<()> { + let matrix = get_matrix(&bridge).await?; + matrix + .delete_message( + &room_id.into_typed()?, + &event_id.parse::()?, + reason, + ) + .await +} + +ts_type_de!(RpcMediaSource: MediaSource = "any"); +#[macro_rules_derive(rpc_method!)] +async fn matrixDownloadFile( + bridge: Arc, + path: PathBuf, + media_source: RpcMediaSource, +) -> anyhow::Result { + let matrix = get_matrix(&bridge).await?; + let content = matrix.download_file(media_source.0).await?; + bridge.storage.write_file(&path, content).await?; + Ok(bridge.storage.platform_path(&path)) +} + +#[macro_rules_derive(rpc_method!)] +async fn matrixStartPoll( + bridge: Arc, + room_id: RpcRoomId, + question: String, + answers: Vec, +) -> anyhow::Result<()> { + let matrix = get_matrix(&bridge).await?; + matrix + .start_poll(&room_id.into_typed()?, question, answers) + .await +} + +#[macro_rules_derive(rpc_method!)] +async fn matrixEndPoll( + bridge: Arc, + room_id: RpcRoomId, + poll_start_id: String, +) -> anyhow::Result<()> { + let matrix = get_matrix(&bridge).await?; + let poll_start_event_id = OwnedEventId::try_from(poll_start_id)?; + matrix + .end_poll(&room_id.into_typed()?, &poll_start_event_id) + .await +} + +#[macro_rules_derive(rpc_method!)] +async fn matrixRespondToPoll( + bridge: Arc, + room_id: RpcRoomId, + poll_start_id: String, + selections: Vec, +) -> anyhow::Result<()> { + let matrix = get_matrix(&bridge).await?; + let poll_start_event_id = OwnedEventId::try_from(poll_start_id)?; + matrix + .respond_to_poll(&room_id.into_typed()?, &poll_start_event_id, selections) + .await +} // converts from a typed handler into untyped handler async fn handle_wrapper( f: F, @@ -1321,9 +1440,11 @@ rpc_methods!(RpcMethods { validateEcash, cancelEcash, // Transactions + updateCachedFiatFXInfo, listTransactions, updateTransactionNotes, // Recovery + backupNow, getMnemonic, checkMnemonic, recoverFromMnemonic, @@ -1341,9 +1462,6 @@ rpc_methods!(RpcMethods { signLnurlMessage, // backup backupStatus, - // XMPP - xmppCredentials, - backupXmppUsername, // Nostr getNostrPubkey, getNostrSecret, @@ -1386,6 +1504,7 @@ rpc_methods!(RpcMethods { matrixRoomObserveTimelineItemsPaginateBackwards, matrixSendMessage, matrixSendMessageJson, + matrixSendAttachment, matrixRoomCreate, matrixRoomCreateOrGetDm, matrixRoomJoin, @@ -1415,6 +1534,12 @@ rpc_methods!(RpcMethods { matrixRoomPreviewContent, matrixPublicRoomInfo, matrixRoomMarkAsUnread, + matrixEditMessage, + matrixDeleteMessage, + matrixDownloadFile, + matrixStartPoll, + matrixEndPoll, + matrixRespondToPoll, // Communities communityPreview, @@ -1438,7 +1563,8 @@ pub async fn fedimint_rpc_async(bridge: Arc, method: String, payload: St let _g = TimeReporter::new(format!("fedimint_rpc {method}")).level(Level::INFO); let sensitive_log = bridge.sensitive_log().await; if sensitive_log { - tracing::info!(%payload); + let trunc_fmt = format!("{payload:.1000}"); + tracing::info!(payload = %trunc_fmt); } else { info!("rpc call"); } @@ -1446,8 +1572,18 @@ pub async fn fedimint_rpc_async(bridge: Arc, method: String, payload: St let result = RpcMethods::handle(bridge, &method, payload).await; if sensitive_log { - tracing::info!(?result); + match &result { + Ok(ok) => { + let trunc_fmt = format!("{ok:.1000}"); + tracing::info!(result_ok = %trunc_fmt); + } + Err(err) => { + let trunc_fmt = format!("{err:.1000}"); + tracing::info!(result_err = %trunc_fmt); + } + } } + result.unwrap_or_else(|error| { error!(%error, "rpc_error"); rpc_error(&error) @@ -1472,10 +1608,8 @@ mod tests { use fedi_core::envs::FEDI_SOCIAL_RECOVERY_MODULE_ENABLE_ENV; use fedi_social_client::common::VerificationDocument; use fedimint_core::core::ModuleKind; - use fedimint_core::task::sleep_in_test; use fedimint_core::{apply, async_trait_maybe_send, Amount}; use fedimint_logging::TracingSetup; - use fedimint_wallet_client::WalletClientModule; use tokio::sync::Mutex; use tracing::{error, info}; @@ -1483,10 +1617,13 @@ mod tests { use crate::api::{RegisterDeviceError, RegisteredDevice}; use crate::community::CommunityInvite; use crate::constants::{COMMUNITY_INVITE_CODE_HRP, FEDI_FILE_PATH, MILLION}; + use crate::envs::USE_UPSTREAM_FEDIMINTD_ENV; use crate::event::{DeviceRegistrationEvent, TransactionEvent}; use crate::features::RuntimeEnvironment; + use crate::federation_v2::client::ClientExt; use crate::federation_v2::FederationV2; use crate::ffi::PathBasedStorage; + use crate::logging::default_log_filter; use crate::storage::{DeviceIdentifier, FediFeeSchedule, IStorage}; use crate::types::{ RpcLnReceiveState, RpcLnState, RpcOOBReissueState, RpcOOBState, RpcOnchainDepositState, @@ -1529,14 +1666,22 @@ mod tests { struct MockFediApi { // (seed, index) => (device identifier, last registration timestamp) registry: Mutex>, + + // Invoice that will be returned whenever fetch_fedi_invoice is called + fedi_fee_invoice: Option, } impl MockFediApi { fn new() -> Self { Self { registry: Mutex::new(HashMap::new()), + fedi_fee_invoice: None, } } + + fn set_fedi_fee_invoice(&mut self, invoice: Bolt11Invoice) { + self.fedi_fee_invoice = Some(invoice); + } } #[apply(async_trait_maybe_send!)] @@ -1555,7 +1700,9 @@ mod tests { _module: ModuleKind, _tx_direction: RpcTransactionDirection, ) -> anyhow::Result { - unimplemented!("TODO shaurya implement when testing"); + self.fedi_fee_invoice + .clone() + .ok_or(anyhow!("Invoice not set")) } async fn fetch_registered_devices_for_seed( @@ -1633,7 +1780,11 @@ mod tests { .unwrap() .parse() .unwrap(); - let gateways = federation.list_gateways().await?; + let mut gateways = federation.list_gateways().await?; + if gateways.is_empty() { + federation.select_gateway().await?; + gateways = federation.list_gateways().await?; + } for gateway in gateways { if gateway.node_pub_key.0 == lnd_node_pubkey { federation.switch_gateway(&gateway.gateway_id.0).await?; @@ -1784,14 +1935,29 @@ mod tests { device_identifier: String, fedi_api: Arc, feature_catalog: Arc, + ) -> anyhow::Result> { + setup_bridge_custom_with_data_dir( + device_identifier, + fedi_api, + feature_catalog, + create_data_dir(), + ) + .await + } + + async fn setup_bridge_custom_with_data_dir( + device_identifier: String, + fedi_api: Arc, + feature_catalog: Arc, + data_dir: PathBuf, ) -> anyhow::Result> { INIT_TRACING.call_once(|| { TracingSetup::default() + .with_directive(&default_log_filter()) .init() .expect("Failed to initialize tracing"); }); let event_sink = Arc::new(FakeEventSink::new()); - let data_dir = create_data_dir(); let storage = Arc::new(PathBasedStorage::new(data_dir).await?); let bridge = match fedimint_initialize_async( storage, @@ -1834,6 +2000,16 @@ mod tests { .await?; Ok(federation) } + + fn should_skip_test_using_stock_fedimintd() -> bool { + if std::env::var(USE_UPSTREAM_FEDIMINTD_ENV).is_ok() { + info!("Skipping test as we're using stock/upstream fedimintd binary"); + true + } else { + false + } + } + #[tokio::test(flavor = "multi_thread")] async fn test_doesnt_overwrite_seed_in_invalid_fedi_file() -> anyhow::Result<()> { INIT_TRACING.call_once(|| { @@ -1913,7 +2089,10 @@ mod tests { // listTransactions works let federations = listFederations(bridge.clone()).await?; assert_eq!(federations.len(), 1); - assert_eq!(env_invite_code.clone(), federations[0].invite_code); + let RpcFederationMaybeLoading::Ready(rpc_federation) = &federations[0] else { + panic!("federation is not loaded"); + }; + assert_eq!(env_invite_code.clone(), rpc_federation.invite_code); // leaveFederation works leaveFederation(bridge.clone(), federation.rpc_federation_id()).await?; @@ -1926,6 +2105,61 @@ mod tests { Ok(()) } + #[tokio::test(flavor = "multi_thread")] + async fn test_join_concurrent() -> anyhow::Result<()> { + let device_identifier = "bridge:test:70c2ad23-bfac-4aa2-81c3-d6f5e79ae724".to_string(); + + let mock_fedi_api = Arc::new(MockFediApi::new()); + let data_dir = create_data_dir(); + let federation_id; + let amount; + // first app launch + { + let bridge = setup_bridge_custom_with_data_dir( + device_identifier.clone(), + mock_fedi_api.clone(), + FeatureCatalog::new(RuntimeEnvironment::Dev).into(), + data_dir.clone(), + ) + .await?; + let env_invite_code = std::env::var("FM_INVITE_CODE").unwrap(); + + // Can't re-join a federation we're already a member of + let (res1, res2) = tokio::join!( + joinFederation(bridge.clone(), env_invite_code.clone(), false), + joinFederation(bridge.clone(), env_invite_code.clone(), false), + ); + federation_id = match (res1, res2) { + (Ok(f), Err(_)) | (Err(_), Ok(f)) => f.id, + _ => panic!("exactly one of two concurrent join federation must fail"), + }; + + let federation = bridge.get_federation(&federation_id.0).await?; + let ecash = cli_generate_ecash(fedimint_core::Amount::from_msats(10_000)).await?; + amount = receiveEcash(federation.clone(), ecash).await?.0; + wait_for_ecash_reissue(&federation).await?; + bridge + .task_group + .clone() + .shutdown_join_all(Duration::from_secs(5)) + .await?; + } + + // second app launch + { + let bridge = setup_bridge_custom_with_data_dir( + device_identifier, + mock_fedi_api, + FeatureCatalog::new(RuntimeEnvironment::Dev).into(), + data_dir, + ) + .await?; + let rpc_federation = wait_for_federation_ready(bridge, federation_id).await?; + assert_eq!(rpc_federation.balance.0, amount); + } + Ok(()) + } + #[tokio::test(flavor = "multi_thread")] async fn test_lightning_send_and_receive() -> anyhow::Result<()> { // Vec of tuple of (send_ppm, receive_ppm) @@ -2219,27 +2453,10 @@ mod tests { ),); let btc_amount = Amount::from_sats(10_000_000); - let pegin_fees = federation - .client - .get_first_module::() - .get_fee_consensus() - .peg_in_abs; - let receive_fedi_fee = Amount::ZERO; - // FIXME: implement fedi fees - // let receive_fedi_fee = Amount::from_msats( - // ((btc_amount.msats - pegin_fees.msats) * - // fedi_fees_receive_ppm).div_ceil(MILLION), ); - // wait for balance to trickle in atmost 10s - for _ in 0..100 { - if btc_amount == federation.get_balance().await + receive_fedi_fee + pegin_fees { - break; - } - sleep_in_test( - "waiting for balance to trickle in", - Duration::from_millis(100), - ) - .await; - } + let pegin_fees = federation.client.wallet()?.get_fee_consensus().peg_in_abs; + let receive_fedi_fee = Amount::from_msats( + ((btc_amount.msats - pegin_fees.msats) * fedi_fees_receive_ppm).div_ceil(MILLION), + ); assert_eq!( btc_amount, federation.get_balance().await + receive_fedi_fee + pegin_fees, @@ -2277,11 +2494,17 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn test_backup_and_recovery() -> anyhow::Result<()> { + if should_skip_test_using_stock_fedimintd() { + return Ok(()); + } test_backup_and_recovery_inner(false).await } #[tokio::test(flavor = "multi_thread")] async fn test_backup_and_recovery_from_scratch() -> anyhow::Result<()> { + if should_skip_test_using_stock_fedimintd() { + return Ok(()); + } test_backup_and_recovery_inner(true).await } @@ -2311,9 +2534,7 @@ mod tests { let ecash_balance_before = federation.get_balance().await; - // set username and do a backup - let username = "satoshi".to_string(); - backupXmppUsername(federation.clone(), username.clone()).await?; + backupNow(federation.clone()).await?; // give some time for backup to complete before shutting down the bridge fedimint_core::task::sleep(Duration::from_secs(1)).await; @@ -2378,6 +2599,10 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn test_social_backup_and_recovery() -> anyhow::Result<()> { + if should_skip_test_using_stock_fedimintd() { + return Ok(()); + } + std::env::set_var(FEDI_SOCIAL_RECOVERY_MODULE_ENABLE_ENV, "1"); let (original_bridge, federation) = setup().await?; @@ -2409,8 +2634,7 @@ mod tests { // set username and do a backup let federation_id = federation.rpc_federation_id(); - let username = "satoshi".to_string(); - backupXmppUsername(federation.clone(), username.clone()).await?; + backupNow(federation.clone()).await?; // Get original mnemonic (for comparison later) let initial_words = getMnemonic(original_bridge.clone()).await?; @@ -2491,13 +2715,8 @@ mod tests { // Assert that balances are correct let recovery_federation = recovery_bridge - .federations - .lock() - .await - .clone() - .into_values() - .next() - .ok_or(anyhow!("Rejoined federation must exist"))?; + .get_federation_maybe_recovering(&federation_id.0) + .await?; assert!(recovery_federation.recovering()); let id = recovery_federation.rpc_federation_id(); drop(recovery_federation); @@ -2531,6 +2750,10 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn test_stability_pool() -> anyhow::Result<()> { + if should_skip_test_using_stock_fedimintd() { + return Ok(()); + } + // Vec of tuple of (send_ppm, receive_ppm) let fee_ppm_values = vec![(0, 0), (10, 5), (100, 50)]; for (send_ppm, receive_ppm) in fee_ppm_values { @@ -2697,8 +2920,7 @@ mod tests { federation.receive_ecash(ecash).await?; wait_for_ecash_reissue(&federation).await?; let federation_id = federation.rpc_federation_id(); - let username = "satoshi".to_string(); - backupXmppUsername(federation.clone(), username.clone()).await?; + backupNow(federation.clone()).await?; // extract mnemonic, leave federation and drop bridge let mnemonic = getMnemonic(bridge.clone()).await?; @@ -2740,9 +2962,7 @@ mod tests { RpcDeviceIndexAssignmentStatus::Assigned(0) )); - // set username and do a backup - let username = "satoshi".to_string(); - backupXmppUsername(federation.clone(), username.clone()).await?; + backupNow(federation.clone()).await?; // give some time for backup to complete before shutting down the bridge fedimint_core::task::sleep(Duration::from_secs(1)).await; @@ -2775,6 +2995,10 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn test_transfer_device_registration_no_feds() -> anyhow::Result<()> { + if should_skip_test_using_stock_fedimintd() { + return Ok(()); + } + let device_identifier_1 = "bridge_1:test:add59709-395e-4563-9cbd-b34ab20dea75".to_string(); let mock_fedi_api = Arc::new(MockFediApi::new()); let bridge_1 = setup_bridge_custom( @@ -2848,6 +3072,10 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn test_transfer_device_registration_post_recovery() -> anyhow::Result<()> { + if should_skip_test_using_stock_fedimintd() { + return Ok(()); + } + let device_identifier_1 = "bridge_1:test:add59709-395e-4563-9cbd-b34ab20dea75".to_string(); let mock_fedi_api = Arc::new(MockFediApi::new()); let (backup_bridge, federation) = setup_custom( @@ -2880,9 +3108,7 @@ mod tests { let ecash_balance_before = federation.get_balance().await; - // set username and do a backup - let username = "satoshi".to_string(); - backupXmppUsername(federation.clone(), username.clone()).await?; + backupNow(federation.clone()).await?; // give some time for backup to complete before shutting down the bridge fedimint_core::task::sleep(Duration::from_secs(1)).await; @@ -2927,10 +3153,6 @@ mod tests { ecash_balance_before + expected_fedi_fee, recovery_federation.get_balance().await ); - assert_eq!( - Some(username), - recovery_federation.get_xmpp_username().await - ); let account_info = stabilityPoolAccountInfo(recovery_federation.clone(), true).await?; assert_eq!(account_info.idle_balance.0, Amount::ZERO); @@ -2966,6 +3188,10 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn test_new_device_registration_post_recovery() -> anyhow::Result<()> { + if should_skip_test_using_stock_fedimintd() { + return Ok(()); + } + let device_identifier_1 = "bridge_1:test:add59709-395e-4563-9cbd-b34ab20dea75".to_string(); let mock_fedi_api = Arc::new(MockFediApi::new()); let (backup_bridge, federation) = setup_custom( @@ -2986,9 +3212,7 @@ mod tests { let amount_to_deposit = Amount::from_msats(110_000); stabilityPoolDepositToSeek(federation.clone(), RpcAmount(amount_to_deposit)).await?; - // set username and do a backup - let username = "satoshi".to_string(); - backupXmppUsername(federation.clone(), username.clone()).await?; + backupNow(federation.clone()).await?; // give some time for backup to complete before shutting down the bridge fedimint_core::task::sleep(Duration::from_secs(1)).await; @@ -3015,7 +3239,6 @@ mod tests { let recovery_federation = join_test_fed_recovery(&recovery_bridge, false).await?; assert!(!recovery_federation.recovering()); assert_eq!(Amount::ZERO, recovery_federation.get_balance().await); - assert_eq!(None, recovery_federation.get_xmpp_username().await); let account_info = stabilityPoolAccountInfo(recovery_federation.clone(), true).await?; assert_eq!(account_info.idle_balance.0, Amount::ZERO); @@ -3295,4 +3518,208 @@ mod tests { Ok(()) } + + #[tokio::test(flavor = "multi_thread")] + async fn test_fee_remittance_on_startup() -> anyhow::Result<()> { + if should_skip_test_using_stock_fedimintd() { + return Ok(()); + } + + // Setup bridge, join test federation, set SP send fee ppm + let device_identifier = "bridge_1:test:add59709-395e-4563-9cbd-b34ab20dea75".to_string(); + let (bridge, federation) = setup_custom( + device_identifier.clone(), + Arc::new(MockFediApi::new()), + FeatureCatalog::new(RuntimeEnvironment::Dev).into(), + ) + .await?; + setStabilityPoolModuleFediFeeSchedule( + bridge.clone(), + federation.rpc_federation_id(), + 210_000, + 0, + ) + .await?; + + // Receive ecash, verify no pending or outstanding fees + let ecash = cli_generate_ecash(Amount::from_msats(2_000_000)).await?; + let ecash_receive_amount = amount_from_ecash(ecash.clone()).await?; + federation.receive_ecash(ecash).await?; + wait_for_ecash_reissue(&federation).await?; + assert_eq!(ecash_receive_amount, federation.get_balance().await); + assert_eq!(Amount::ZERO, federation.get_pending_fedi_fees().await); + assert_eq!(Amount::ZERO, federation.get_outstanding_fedi_fees().await); + + // Make SP deposit, verify pending fees + let amount_to_deposit = Amount::from_msats(1_000_000); + stabilityPoolDepositToSeek(federation.clone(), RpcAmount(amount_to_deposit)).await?; + assert_eq!( + Amount::from_msats(210_000), + federation.get_pending_fedi_fees().await + ); + assert_eq!(Amount::ZERO, federation.get_outstanding_fedi_fees().await); + + // Wait for SP deposit to be accepted, verify outstanding fees + loop { + // Wait until deposit operation succeeds + // Initiated -> TxAccepted -> Success + if bridge + .event_sink + .num_events_of_type("stabilityPoolDeposit".into()) + == 3 + { + break; + } + + fedimint_core::task::sleep(Duration::from_millis(100)).await; + } + assert_eq!(Amount::ZERO, federation.get_pending_fedi_fees().await); + assert_eq!( + Amount::from_msats(210_000), + federation.get_outstanding_fedi_fees().await + ); + + // No fee can be remitted just yet cuz we haven't mocked invoice endpoint + + // Extract data dir and drop bridge + let federation_id = federation.federation_id(); + let data_dir = bridge.storage.platform_path(Path::new("")); + drop(federation); + bridge + .task_group + .clone() + .shutdown_join_all(Duration::from_secs(5)) + .await?; + drop(bridge); + + // Mock fee remittance endpoint + let label = "fedi_fee_app_startup"; + let fedi_fee_invoice = cli_generate_invoice(label, &Amount::from_msats(210_000)).await?; + let mut mock_fedi_api = MockFediApi::new(); + mock_fedi_api.set_fedi_fee_invoice(fedi_fee_invoice.clone()); + + // Create new bridge using same data dir + let new_bridge = setup_bridge_custom_with_data_dir( + device_identifier, + Arc::new(mock_fedi_api), + FeatureCatalog::new(RuntimeEnvironment::Dev).into(), + data_dir, + ) + .await?; + + // Wait for fedi fee to be remitted (timeout of 5s) + fedimint_core::task::timeout(Duration::from_secs(5), cln_wait_invoice(label)).await??; + + // Ensure outstanding fee has been cleared + let federation = new_bridge + .get_federation(&federation_id.to_string()) + .await?; + assert_eq!(Amount::ZERO, federation.get_pending_fedi_fees().await); + assert_eq!(Amount::ZERO, federation.get_outstanding_fedi_fees().await); + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_fee_remittance_post_successful_tx() -> anyhow::Result<()> { + if should_skip_test_using_stock_fedimintd() { + return Ok(()); + } + + // Mock fee remittance endpoint + let label = "fedi_fee_post_tx"; + let fedi_fee_invoice = cli_generate_invoice(label, &Amount::from_msats(210_000)).await?; + let mut mock_fedi_api = MockFediApi::new(); + mock_fedi_api.set_fedi_fee_invoice(fedi_fee_invoice.clone()); + + // Setup bridge, join test federation, set SP send fee ppm + let device_identifier = "bridge_1:test:add59709-395e-4563-9cbd-b34ab20dea75".to_string(); + let (bridge, federation) = setup_custom( + device_identifier.clone(), + Arc::new(mock_fedi_api), + FeatureCatalog::new(RuntimeEnvironment::Dev).into(), + ) + .await?; + setStabilityPoolModuleFediFeeSchedule( + bridge.clone(), + federation.rpc_federation_id(), + 210_000, + 0, + ) + .await?; + + // Receive ecash, verify no pending or outstanding fees + let ecash = cli_generate_ecash(Amount::from_msats(2_000_000)).await?; + let ecash_receive_amount = amount_from_ecash(ecash.clone()).await?; + federation.receive_ecash(ecash).await?; + wait_for_ecash_reissue(&federation).await?; + assert_eq!(ecash_receive_amount, federation.get_balance().await); + assert_eq!(Amount::ZERO, federation.get_pending_fedi_fees().await); + assert_eq!(Amount::ZERO, federation.get_outstanding_fedi_fees().await); + + // Make SP deposit, verify pending fees + let amount_to_deposit = Amount::from_msats(1_000_000); + stabilityPoolDepositToSeek(federation.clone(), RpcAmount(amount_to_deposit)).await?; + assert_eq!( + Amount::from_msats(210_000), + federation.get_pending_fedi_fees().await + ); + assert_eq!(Amount::ZERO, federation.get_outstanding_fedi_fees().await); + + // Wait for SP deposit to be accepted, verify fee remittance + loop { + // Wait until deposit operation succeeds + // Initiated -> TxAccepted -> Success + if bridge + .event_sink + .num_events_of_type("stabilityPoolDeposit".into()) + == 3 + { + break; + } + + fedimint_core::task::sleep(Duration::from_millis(100)).await; + } + + // Wait for fedi fee to be remitted + fedimint_core::task::timeout(Duration::from_secs(30), cln_wait_invoice(label)).await??; + + // Ensure outstanding fee has been cleared + assert_eq!(Amount::ZERO, federation.get_pending_fedi_fees().await); + assert_eq!(Amount::ZERO, federation.get_outstanding_fedi_fees().await); + + Ok(()) + } + + async fn wait_for_federation_ready( + bridge: Arc, + federation_id: RpcFederationId, + ) -> anyhow::Result { + fedimint_core::task::timeout(Duration::from_secs(2), async move { + 'check: loop { + let federations = bridge.list_federations().await; + let rpc_federation = federations.into_iter().find_map(|f| { + if let RpcFederationMaybeLoading::Ready(fed @ RpcFederation { .. }) = f { + if fed.id == federation_id { + Some(fed) + } else { + None + } + } else { + None + } + }); + + if let Some(rpc_federation) = rpc_federation { + break 'check Ok::<_, anyhow::Error>(rpc_federation); + } + fedimint_core::task::sleep_in_test( + "waiting for federation ready event", + Duration::from_millis(100), + ) + .await; + } + }) + .await? + } } diff --git a/bridge/fedi-ffi/src/storage.rs b/bridge/fedi-ffi/src/storage.rs index 58933ef..10fc3f0 100644 --- a/bridge/fedi-ffi/src/storage.rs +++ b/bridge/fedi-ffi/src/storage.rs @@ -93,6 +93,11 @@ pub struct AppStateRaw { pub matrix_display_name: Option, // NOTE: if you ever remove fields from AppState, don't delete the field. // just comment it out to prevent field reuse in future. + + // App State stores a cached copy of the app's display currency along with the BTC -> display + // currency exchange rate. This cached info is used to attach historical fiat values to TXs as + // they are recorded. + pub cached_fiat_fx_info: Option, } #[derive(Debug, Clone, PartialEq)] @@ -283,6 +288,16 @@ pub struct CommunityInfo { pub meta: CommunityJson, } +#[derive(Clone, Serialize, Deserialize)] +pub struct FiatFXInfo { + /// Code of the currency that's set as display currency in the app. + pub fiat_code: String, + + /// 1 BTC equivalent in the display currency. This value is recorded in + /// hundredths, such as cents. + pub btc_to_fiat_hundredths: u64, +} + pub struct AppState { raw: RwLock>, storage: Storage, @@ -347,6 +362,7 @@ impl AppState { last_device_registration_timestamp: None, next_federation_db_prefix: default_next_federation_prefix(), matrix_display_name: None, + cached_fiat_fx_info: None, })), storage, } @@ -502,6 +518,11 @@ impl AppState { }) .await } + + pub async fn get_cached_fiat_fx_info(&self) -> Option { + self.with_read_lock(|state| state.cached_fiat_fx_info.clone()) + .await + } } #[cfg(test)] diff --git a/bridge/fedi-ffi/src/types.rs b/bridge/fedi-ffi/src/types.rs index 13852f7..16b46c1 100644 --- a/bridge/fedi-ffi/src/types.rs +++ b/bridge/fedi-ffi/src/types.rs @@ -68,6 +68,16 @@ pub struct RpcFederation { pub fedi_fee_schedule: RpcFediFeeSchedule, } +#[derive(Debug, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[serde(tag = "init_state")] +#[ts(export, export_to = "target/bindings/")] +pub enum RpcFederationMaybeLoading { + Loading { id: RpcFederationId }, + Failed { error: String, id: RpcFederationId }, + Ready(RpcFederation), +} + #[derive(Debug, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = "target/bindings/")] @@ -286,18 +296,16 @@ pub struct RpcLightningGateway { pub active: bool, } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, Default)] pub struct FediBackupMetadata { - // TODO: would be nice to rename this to xmpp_username but would need to basically migrate the - // backups - pub username: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + // don't use this field + username: Option, } impl FediBackupMetadata { - pub fn new(xmpp_username: Option) -> Self { - Self { - username: xmpp_username, - } + pub fn new() -> Self { + Self { username: None } } } @@ -353,6 +361,15 @@ pub struct RpcSignedLnurlMessage { pub pubkey: RpcPublicKey, } +#[derive(Debug, Serialize, Deserialize, Clone, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "target/bindings/")] +pub struct RpcMediaUploadParams { + pub width: Option, + pub height: Option, + pub mime_type: String, +} + #[derive( Debug, Clone, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize, TS, Encodable, Decodable, )] @@ -392,6 +409,94 @@ pub struct RpcTransaction { pub oob_state: Option, pub onchain_withdrawal_details: Option, pub stability_pool_state: Option, + pub tx_date_fiat_info: Option, +} + +impl RpcTransaction { + pub fn new( + id: String, + created_at: u64, + amount: RpcAmount, + direction: RpcTransactionDirection, + fedi_fee_status: Option, + tx_date_fiat_info: Option, + ) -> Self { + Self { + id, + created_at, + amount, + direction, + fedi_fee_status, + notes: Default::default(), + onchain_state: Default::default(), + bitcoin: Default::default(), + ln_state: Default::default(), + lightning: Default::default(), + oob_state: Default::default(), + onchain_withdrawal_details: Default::default(), + stability_pool_state: Default::default(), + tx_date_fiat_info, + } + } + + pub fn with_notes(self, notes: String) -> Self { + Self { notes, ..self } + } + + pub fn with_onchain_state(self, onchain_state: RpcOnchainState) -> Self { + Self { + onchain_state: Some(onchain_state), + ..self + } + } + + pub fn with_bitcoin(self, bitcoin: RpcBitcoinDetails) -> Self { + Self { + bitcoin: Some(bitcoin), + ..self + } + } + + pub fn with_ln_state(self, ln_state: RpcLnState) -> Self { + Self { + ln_state: Some(ln_state), + ..self + } + } + + pub fn with_lightning(self, lightning: RpcLightningDetails) -> Self { + Self { + lightning: Some(lightning), + ..self + } + } + + pub fn with_oob_state(self, oob_state: RpcOOBState) -> Self { + Self { + oob_state: Some(oob_state), + ..self + } + } + + pub fn with_onchain_withdrawal_details( + self, + onchain_withdrawal_details: WithdrawalDetails, + ) -> Self { + Self { + onchain_withdrawal_details: Some(onchain_withdrawal_details), + ..self + } + } + + pub fn with_stability_pool_state( + self, + stability_pool_state: RpcStabilityPoolTransactionState, + ) -> Self { + Self { + stability_pool_state: Some(stability_pool_state), + ..self + } + } } #[derive(Debug, Serialize, Deserialize, TS)] @@ -425,8 +530,8 @@ pub enum RpcOnchainState { } impl RpcOnchainState { - pub fn from_deposit_state(opt: Option) -> Option { - let state = match opt? { + pub fn from_deposit_state(state: DepositStateV2) -> RpcOnchainState { + Self::DepositState(match state { DepositStateV2::WaitingForTransaction => RpcOnchainDepositState::WaitingForTransaction, DepositStateV2::WaitingForConfirmation { btc_out_point, .. } => { RpcOnchainDepositState::WaitingForConfirmation( @@ -436,13 +541,15 @@ impl RpcOnchainState { DepositStateV2::Claimed { btc_out_point, .. } => RpcOnchainDepositState::Claimed( RpcOnchainDepositTransactionData::new(&btc_out_point), ), + DepositStateV2::Confirmed { btc_out_point, .. } => RpcOnchainDepositState::Confirmed( + RpcOnchainDepositTransactionData::new(&btc_out_point), + ), DepositStateV2::Failed(_) => RpcOnchainDepositState::Failed, - }; - Some(Self::DepositState(state)) + }) } - pub fn from_withdraw_state(opt: Option) -> Option { - opt.map(|state| match state { + pub fn from_withdraw_state(state: WithdrawState) -> RpcOnchainState { + match state { WithdrawState::Created => { RpcOnchainState::WithdrawState(RpcOnchainWithdrawState::Created) } @@ -452,7 +559,7 @@ impl RpcOnchainState { WithdrawState::Failed(_) => { RpcOnchainState::WithdrawState(RpcOnchainWithdrawState::Failed) } - }) + } } } @@ -463,6 +570,7 @@ impl RpcOnchainState { pub enum RpcOnchainDepositState { WaitingForTransaction, WaitingForConfirmation(RpcOnchainDepositTransactionData), + Confirmed(RpcOnchainDepositTransactionData), Claimed(RpcOnchainDepositTransactionData), Failed, } @@ -509,8 +617,8 @@ pub enum RpcLnState { } impl RpcLnState { - pub fn from_ln_recv_state(opt: Option) -> Option { - opt.map(|state| match state { + pub fn from_ln_recv_state(state: LnReceiveState) -> RpcLnState { + match state { LnReceiveState::Created => RpcLnState::RecvState(RpcLnReceiveState::Created), LnReceiveState::WaitingForPayment { invoice, timeout } => { RpcLnState::RecvState(RpcLnReceiveState::WaitingForPayment { invoice, timeout }) @@ -525,10 +633,10 @@ impl RpcLnState { RpcLnState::RecvState(RpcLnReceiveState::AwaitingFunds) } LnReceiveState::Claimed => RpcLnState::RecvState(RpcLnReceiveState::Claimed), - }) + } } - pub fn from_ln_pay_state(opt: Option) -> Option { - opt.map(|state| match state { + pub fn from_ln_pay_state(state: LnPayState) -> RpcLnState { + match state { LnPayState::Created => RpcLnState::PayState(RpcLnPayState::Created), LnPayState::Canceled => RpcLnState::PayState(RpcLnPayState::Canceled), LnPayState::Funded { block_height } => { @@ -545,7 +653,7 @@ impl RpcLnState { RpcLnState::PayState(RpcLnPayState::Refunded { gateway_error }) } LnPayState::UnexpectedError { .. } => RpcLnState::PayState(RpcLnPayState::Failed), - }) + } } } @@ -664,23 +772,18 @@ pub struct RpcLightningDetails { pub fee: Option, } -// FIXME: should probaby type these as bytes -#[derive(Deserialize, Serialize, TS)] +// In order to display time-of-transaction fiat value and currency, we need to +// store this info for each transaction. We store the currency code as simply a +// string so that new currency codes added on the front-end side don't require +// additional bridge work. The value is recorded as hundredths, which would +// typically correspond to cents. +#[derive(Debug, Serialize, Deserialize, TS, Encodable, Decodable)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = "target/bindings/")] -pub struct RpcXmppCredentials { - pub password: String, - pub keypair_seed: String, - pub username: Option, -} - -// Implement Debug manually to ignore sensitive fields -impl std::fmt::Debug for RpcXmppCredentials { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("RpcXmppCredentials") - .field("username", &self.username) - .finish() - } +pub struct TransactionDateFiatInfo { + pub fiat_code: String, + #[ts(type = "number")] + pub fiat_value_hundredths: u64, } #[derive(Debug, Deserialize, Serialize)] @@ -776,7 +879,7 @@ impl From for RpcStabilityPoolAccountInfo { /// is to be debited) is not in the user's possession until the /// operation completes. So for receives, we just record the ppm, and when the /// operation succeeds, we debit the fee. -#[derive(Debug, Encodable, Decodable)] +#[derive(Debug, Encodable, Decodable, Clone)] pub enum OperationFediFeeStatus { PendingSend { fedi_fee: Amount }, PendingReceive { fedi_fee_ppm: u64 }, diff --git a/flake.lock b/flake.lock index a053232..9bc2b3b 100644 --- a/flake.lock +++ b/flake.lock @@ -40,7 +40,32 @@ "android-nixpkgs_2": { "inputs": { "devshell": "devshell_2", - "flake-utils": "flake-utils_4", + "flake-utils": "flake-utils_3", + "nixpkgs": [ + "cargo-deluxe", + "flakebox", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1727381935, + "narHash": "sha256-G2fOYRZM7bXK5eBb+GK3k/WmO+q5JA/GtFwSPc3kdc8=", + "owner": "tadfisher", + "repo": "android-nixpkgs", + "rev": "522d86121cbd413aff922c54f38106ecf8740107", + "type": "github" + }, + "original": { + "owner": "tadfisher", + "repo": "android-nixpkgs", + "rev": "522d86121cbd413aff922c54f38106ecf8740107", + "type": "github" + } + }, + "android-nixpkgs_3": { + "inputs": { + "devshell": "devshell_3", + "flake-utils": "flake-utils_7", "nixpkgs": [ "fedimint-pkgs", "flakebox", @@ -62,10 +87,10 @@ "type": "github" } }, - "android-nixpkgs_3": { + "android-nixpkgs_4": { "inputs": { - "devshell": "devshell_3", - "flake-utils": "flake-utils_7", + "devshell": "devshell_4", + "flake-utils": "flake-utils_10", "nixpkgs": [ "flakebox", "nixpkgs" @@ -90,7 +115,7 @@ "inputs": { "nix-bundle": "nix-bundle", "nix-utils": "nix-utils", - "nixpkgs": "nixpkgs_4" + "nixpkgs": "nixpkgs_5" }, "locked": { "lastModified": 1708548996, @@ -107,10 +132,33 @@ "type": "github" } }, + "cargo-deluxe": { + "inputs": { + "flake-utils": "flake-utils_2", + "flakebox": "flakebox", + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1729293186, + "narHash": "sha256-6u3NWX1pbofzwAcSOE5TX+5O1FxuhIr4nQB13vneJtQ=", + "owner": "rustshop", + "repo": "cargo-deluxe", + "rev": "da124f8fffa731a647420065f204601f9a20b289", + "type": "github" + }, + "original": { + "owner": "rustshop", + "repo": "cargo-deluxe", + "rev": "da124f8fffa731a647420065f204601f9a20b289", + "type": "github" + } + }, "crane": { "inputs": { "nixpkgs": [ - "fedimint-pkgs", + "cargo-deluxe", "flakebox", "nixpkgs" ] @@ -133,6 +181,7 @@ "crane_2": { "inputs": { "nixpkgs": [ + "fedimint-pkgs", "flakebox", "nixpkgs" ] @@ -153,9 +202,31 @@ } }, "crane_3": { + "inputs": { + "nixpkgs": [ + "flakebox", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1717383740, + "narHash": "sha256-559HbY4uhNeoYvK3H6AMZAtVfmR3y8plXZ1x6ON/cWU=", + "owner": "ipetkov", + "repo": "crane", + "rev": "b65673fce97d277934488a451724be94cc62499a", + "type": "github" + }, + "original": { + "owner": "ipetkov", + "repo": "crane", + "rev": "b65673fce97d277934488a451724be94cc62499a", + "type": "github" + } + }, + "crane_4": { "inputs": { "flake-compat": "flake-compat", - "flake-utils": "flake-utils_9", + "flake-utils": "flake-utils_12", "nixpkgs": [ "fs-dir-cache", "nixpkgs" @@ -201,7 +272,7 @@ "devshell_2": { "inputs": { "nixpkgs": [ - "fedimint-pkgs", + "cargo-deluxe", "flakebox", "android-nixpkgs", "nixpkgs" @@ -225,6 +296,7 @@ "devshell_3": { "inputs": { "nixpkgs": [ + "fedimint-pkgs", "flakebox", "android-nixpkgs", "nixpkgs" @@ -245,26 +317,49 @@ "type": "github" } }, + "devshell_4": { + "inputs": { + "nixpkgs": [ + "flakebox", + "android-nixpkgs", + "nixpkgs" + ], + "systems": "systems_12" + }, + "locked": { + "lastModified": 1695195896, + "narHash": "sha256-pq9q7YsGXnQzJFkR5284TmxrLNFc0wo4NQ/a5E93CQU=", + "owner": "numtide", + "repo": "devshell", + "rev": "05d40d17bf3459606316e3e9ec683b784ff28f16", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "devshell", + "type": "github" + } + }, "fedimint-pkgs": { "inputs": { "advisory-db": "advisory-db", "bundlers": "bundlers", - "fenix": "fenix", - "flake-utils": "flake-utils_3", - "flakebox": "flakebox", - "nixpkgs": "nixpkgs_5" + "fenix": "fenix_2", + "flake-utils": "flake-utils_6", + "flakebox": "flakebox_2", + "nixpkgs": "nixpkgs_6" }, "locked": { - "lastModified": 1724250977, - "narHash": "sha256-FMDH6mESzY78NH69D7HdZV3meHloUCbamQmcxN2cxHc=", + "lastModified": 1729102469, + "narHash": "sha256-dowBNwqbYJ3lSk6G0MWjOzQJuDlqlbpcW7/YSu5Phms=", "owner": "fedibtc", "repo": "fedimint", - "rev": "b41b1ef19d3addcf2a94cc1eb1aa82b10275e907", + "rev": "eb40053b816c86192fbfd5e2a61f6d9c1670d789", "type": "github" }, "original": { "owner": "fedibtc", - "ref": "v0.4.2-rc.0-fed", + "ref": "v0.4.3-rc.2-fed4", "repo": "fedimint", "type": "github" } @@ -272,11 +367,34 @@ "fenix": { "inputs": { "nixpkgs": [ - "fedimint-pkgs", + "cargo-deluxe", + "flakebox", "nixpkgs" ], "rust-analyzer-src": "rust-analyzer-src" }, + "locked": { + "lastModified": 1696918968, + "narHash": "sha256-18rAHsM9YsGp7aKKemO4gKIeWfrSyDsdJZ/mk4dQ3JI=", + "owner": "nix-community", + "repo": "fenix", + "rev": "638fc95a2a3d01b372c76f71cbb6d73c63909d6e", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "fenix", + "type": "github" + } + }, + "fenix_2": { + "inputs": { + "nixpkgs": [ + "fedimint-pkgs", + "nixpkgs" + ], + "rust-analyzer-src": "rust-analyzer-src_2" + }, "locked": { "lastModified": 1708928609, "narHash": "sha256-LcXC2NP/TzHMmJThZGG1e+7rht5HeuZK5WOirIDg+lU=", @@ -291,12 +409,12 @@ "type": "github" } }, - "fenix_2": { + "fenix_3": { "inputs": { "nixpkgs": [ "nixpkgs" ], - "rust-analyzer-src": "rust-analyzer-src_2" + "rust-analyzer-src": "rust-analyzer-src_3" }, "locked": { "lastModified": 1716877613, @@ -312,13 +430,13 @@ "type": "github" } }, - "fenix_3": { + "fenix_4": { "inputs": { "nixpkgs": [ "fs-dir-cache", "nixpkgs" ], - "rust-analyzer-src": "rust-analyzer-src_3" + "rust-analyzer-src": "rust-analyzer-src_4" }, "locked": { "lastModified": 1692253212, @@ -370,7 +488,46 @@ }, "flake-utils_10": { "inputs": { - "systems": "systems_12" + "systems": "systems_13" + }, + "locked": { + "lastModified": 1694529238, + "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "ff7b65b44d01cf9ba6a71320833626af21126384", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "flake-utils_11": { + "inputs": { + "systems": [ + "flakebox", + "systems" + ] + }, + "locked": { + "lastModified": 1710146030, + "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "flake-utils_12": { + "inputs": { + "systems": "systems_15" }, "locked": { "lastModified": 1689068808, @@ -386,13 +543,16 @@ "type": "github" } }, - "flake-utils_2": { + "flake-utils_13": { + "inputs": { + "systems": "systems_16" + }, "locked": { - "lastModified": 1623875721, - "narHash": "sha256-A8BU7bjS5GirpAUv4QA+QnJ4CceLHkcXdRp4xITDB0s=", + "lastModified": 1689068808, + "narHash": "sha256-6ixXo3wt24N/melDWjq70UuHQLxGV8jZvooRanIHXw0=", "owner": "numtide", "repo": "flake-utils", - "rev": "f7e004a55b120c02ecb6219596820fcd32ca8772", + "rev": "919d646de7be200f3bf08cb76ae1f09402b6f9b4", "type": "github" }, "original": { @@ -401,16 +561,16 @@ "type": "github" } }, - "flake-utils_3": { + "flake-utils_2": { "inputs": { "systems": "systems_3" }, "locked": { - "lastModified": 1705309234, - "narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=", + "lastModified": 1694529238, + "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", "owner": "numtide", "repo": "flake-utils", - "rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26", + "rev": "ff7b65b44d01cf9ba6a71320833626af21126384", "type": "github" }, "original": { @@ -419,7 +579,7 @@ "type": "github" } }, - "flake-utils_4": { + "flake-utils_3": { "inputs": { "systems": "systems_5" }, @@ -437,20 +597,35 @@ "type": "github" } }, - "flake-utils_5": { + "flake-utils_4": { "inputs": { "systems": [ - "fedimint-pkgs", + "cargo-deluxe", "flakebox", "systems" ] }, "locked": { - "lastModified": 1705309234, - "narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=", + "lastModified": 1694529238, + "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", "owner": "numtide", "repo": "flake-utils", - "rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26", + "rev": "ff7b65b44d01cf9ba6a71320833626af21126384", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "flake-utils_5": { + "locked": { + "lastModified": 1623875721, + "narHash": "sha256-A8BU7bjS5GirpAUv4QA+QnJ4CceLHkcXdRp4xITDB0s=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "f7e004a55b120c02ecb6219596820fcd32ca8772", "type": "github" }, "original": { @@ -464,11 +639,11 @@ "systems": "systems_7" }, "locked": { - "lastModified": 1710146030, - "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", + "lastModified": 1705309234, + "narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=", "owner": "numtide", "repo": "flake-utils", - "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", + "rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26", "type": "github" }, "original": { @@ -498,16 +673,17 @@ "flake-utils_8": { "inputs": { "systems": [ + "fedimint-pkgs", "flakebox", "systems" ] }, "locked": { - "lastModified": 1710146030, - "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", + "lastModified": 1705309234, + "narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=", "owner": "numtide", "repo": "flake-utils", - "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", + "rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26", "type": "github" }, "original": { @@ -521,11 +697,11 @@ "systems": "systems_11" }, "locked": { - "lastModified": 1689068808, - "narHash": "sha256-6ixXo3wt24N/melDWjq70UuHQLxGV8jZvooRanIHXw0=", + "lastModified": 1710146030, + "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", "owner": "numtide", "repo": "flake-utils", - "rev": "919d646de7be200f3bf08cb76ae1f09402b6f9b4", + "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", "type": "github" }, "original": { @@ -538,16 +714,39 @@ "inputs": { "android-nixpkgs": "android-nixpkgs_2", "crane": "crane", + "fenix": "fenix", + "flake-utils": "flake-utils_4", + "nixpkgs": "nixpkgs_2", + "systems": "systems_6" + }, + "locked": { + "lastModified": 1728662483, + "narHash": "sha256-phVyjjgVmHFUF6+DGFRA+1fCV0zs8ipwRn/M33LtIYY=", + "owner": "rustshop", + "repo": "flakebox", + "rev": "1a65534520d8a592fd7d75f7db18cfed40fa5c09", + "type": "github" + }, + "original": { + "owner": "rustshop", + "repo": "flakebox", + "type": "github" + } + }, + "flakebox_2": { + "inputs": { + "android-nixpkgs": "android-nixpkgs_3", + "crane": "crane_2", "fenix": [ "fedimint-pkgs", "fenix" ], - "flake-utils": "flake-utils_5", + "flake-utils": "flake-utils_8", "nixpkgs": [ "fedimint-pkgs", "nixpkgs" ], - "systems": "systems_6" + "systems": "systems_10" }, "locked": { "lastModified": 1719004469, @@ -564,40 +763,40 @@ "type": "github" } }, - "flakebox_2": { + "flakebox_3": { "inputs": { - "android-nixpkgs": "android-nixpkgs_3", - "crane": "crane_2", + "android-nixpkgs": "android-nixpkgs_4", + "crane": "crane_3", "fenix": [ "fenix" ], - "flake-utils": "flake-utils_8", + "flake-utils": "flake-utils_11", "nixpkgs": [ "nixpkgs" ], - "systems": "systems_10" + "systems": "systems_14" }, "locked": { - "lastModified": 1719004469, - "narHash": "sha256-TZSHiEJ3qYgA46vikQKT2bwGCEF2LrJVw7cettqa+/g=", + "lastModified": 1728418516, + "narHash": "sha256-3UF5d2cOxcpY3H68OyjZfWXvKWYCrJaCe5hjHjUqDUA=", "owner": "fedibtc", "repo": "flakebox", - "rev": "12d5ee4f6c47bc01f07ec6f5848a83db265902d3", + "rev": "675075a4049253289e7cce634b1b6443b046ed1b", "type": "github" }, "original": { "owner": "fedibtc", "repo": "flakebox", - "rev": "12d5ee4f6c47bc01f07ec6f5848a83db265902d3", + "rev": "675075a4049253289e7cce634b1b6443b046ed1b", "type": "github" } }, "fs-dir-cache": { "inputs": { - "crane": "crane_3", - "fenix": "fenix_3", - "flake-utils": "flake-utils_10", - "nixpkgs": "nixpkgs_6" + "crane": "crane_4", + "fenix": "fenix_4", + "flake-utils": "flake-utils_13", + "nixpkgs": "nixpkgs_7" }, "locked": { "lastModified": 1693034888, @@ -616,7 +815,7 @@ }, "nix-bundle": { "inputs": { - "nixpkgs": "nixpkgs_2" + "nixpkgs": "nixpkgs_3" }, "locked": { "lastModified": 1708548961, @@ -635,8 +834,8 @@ }, "nix-utils": { "inputs": { - "flake-utils": "flake-utils_2", - "nixpkgs": "nixpkgs_3" + "flake-utils": "flake-utils_5", + "nixpkgs": "nixpkgs_4" }, "locked": { "lastModified": 1632973430, @@ -670,11 +869,11 @@ }, "nixpkgs-unstable": { "locked": { - "lastModified": 1712122226, - "narHash": "sha256-pmgwKs8Thu1WETMqCrWUm0CkN1nmCKX3b51+EXsAZyY=", + "lastModified": 1728241625, + "narHash": "sha256-yumd4fBc/hi8a9QgA9IT8vlQuLZ2oqhkJXHPKxH/tRw=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "08b9151ed40350725eb40b1fe96b0b86304a654b", + "rev": "c31898adf5a8ed202ce5bea9f347b1c6871f32d1", "type": "github" }, "original": { @@ -685,6 +884,22 @@ } }, "nixpkgs_2": { + "locked": { + "lastModified": 1728492678, + "narHash": "sha256-9UTxR8eukdg+XZeHgxW5hQA9fIKHsKCdOIUycTryeVw=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "5633bcff0c6162b9e4b5f1264264611e950c8ec7", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_3": { "locked": { "lastModified": 1620055814, "narHash": "sha256-8LEHoYSJiL901bTMVatq+rf8y7QtWuZhwwpKE2fyaRY=", @@ -699,7 +914,7 @@ "type": "indirect" } }, - "nixpkgs_3": { + "nixpkgs_4": { "locked": { "lastModified": 1629252929, "narHash": "sha256-Aj20gmGBs8TG7pyaQqgbsqAQ6cB+TVuL18Pk3DPBxcQ=", @@ -714,7 +929,7 @@ "type": "github" } }, - "nixpkgs_4": { + "nixpkgs_5": { "locked": { "lastModified": 1634782485, "narHash": "sha256-psfh4OQSokGXG0lpq3zKFbhOo3QfoeudRcaUnwMRkQo=", @@ -727,7 +942,7 @@ "type": "indirect" } }, - "nixpkgs_5": { + "nixpkgs_6": { "locked": { "lastModified": 1721686456, "narHash": "sha256-nw/BnNzATDPfzpJVTnY8mcSKKsz6BJMEFRkJ332QSN0=", @@ -743,7 +958,7 @@ "type": "github" } }, - "nixpkgs_6": { + "nixpkgs_7": { "locked": { "lastModified": 1692207601, "narHash": "sha256-tfPGNKQcJT1cvT6ufqO/7ydYNL6mcJClvzbrzhKjB80=", @@ -759,13 +974,13 @@ "type": "github" } }, - "nixpkgs_7": { + "nixpkgs_8": { "locked": { - "lastModified": 1721686456, - "narHash": "sha256-nw/BnNzATDPfzpJVTnY8mcSKKsz6BJMEFRkJ332QSN0=", + "lastModified": 1728328465, + "narHash": "sha256-a0a0M1TmXMK34y3M0cugsmpJ4FJPT/xsblhpiiX1CXo=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "575f3027caa1e291d24f1e9fb0e3a19c2f26d96b", + "rev": "1bfbbbe5bbf888d675397c66bfdb275d0b99361c", "type": "github" }, "original": { @@ -778,16 +993,34 @@ "root": { "inputs": { "android-nixpkgs": "android-nixpkgs", + "cargo-deluxe": "cargo-deluxe", "fedimint-pkgs": "fedimint-pkgs", - "fenix": "fenix_2", - "flake-utils": "flake-utils_6", - "flakebox": "flakebox_2", + "fenix": "fenix_3", + "flake-utils": "flake-utils_9", + "flakebox": "flakebox_3", "fs-dir-cache": "fs-dir-cache", - "nixpkgs": "nixpkgs_7", + "nixpkgs": "nixpkgs_8", "nixpkgs-unstable": "nixpkgs-unstable" } }, "rust-analyzer-src": { + "flake": false, + "locked": { + "lastModified": 1696840854, + "narHash": "sha256-wphOvjDSDsUN5DMe3MOhdargANIab7YE3hkh3Qv7qso=", + "owner": "rust-lang", + "repo": "rust-analyzer", + "rev": "aaa1e8e1b82d742b876d164a30dda02f318ce809", + "type": "github" + }, + "original": { + "owner": "rust-lang", + "ref": "nightly", + "repo": "rust-analyzer", + "type": "github" + } + }, + "rust-analyzer-src_2": { "flake": false, "locked": { "lastModified": 1708878562, @@ -804,7 +1037,7 @@ "type": "github" } }, - "rust-analyzer-src_2": { + "rust-analyzer-src_3": { "flake": false, "locked": { "lastModified": 1716828004, @@ -821,7 +1054,7 @@ "type": "github" } }, - "rust-analyzer-src_3": { + "rust-analyzer-src_4": { "flake": false, "locked": { "lastModified": 1691752485, @@ -925,6 +1158,66 @@ "type": "github" } }, + "systems_13": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "systems_14": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "systems_15": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "systems_16": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, "systems_2": { "locked": { "lastModified": 1681028828, diff --git a/flake.nix b/flake.nix index aca8ab6..fe856ff 100644 --- a/flake.nix +++ b/flake.nix @@ -6,7 +6,7 @@ nixpkgs-unstable.url = "github:NixOS/nixpkgs/nixos-unstable"; flake-utils.url = "github:numtide/flake-utils"; fedimint-pkgs = { - url = "github:fedibtc/fedimint?ref=v0.4.2-rc.0-fed"; + url = "github:fedibtc/fedimint?ref=v0.4.3-rc.2-fed4"; }; fenix = { @@ -14,7 +14,7 @@ inputs.nixpkgs.follows = "nixpkgs"; }; flakebox = { - url = "github:fedibtc/flakebox?rev=12d5ee4f6c47bc01f07ec6f5848a83db265902d3"; + url = "github:fedibtc/flakebox?rev=675075a4049253289e7cce634b1b6443b046ed1b"; inputs.nixpkgs.follows = "nixpkgs"; inputs.fenix.follows = "fenix"; }; @@ -23,13 +23,18 @@ url = "github:fedibtc/fs-dir-cache?rev=a6371f48f84512ea06a8ac671f9cdc141a732673"; }; + cargo-deluxe = { + url = "github:rustshop/cargo-deluxe?rev=da124f8fffa731a647420065f204601f9a20b289"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + android-nixpkgs = { url = "github:tadfisher/android-nixpkgs?rev=6370a3aafe37ed453bfdc4af578eb26339f8fee0"; # stable # inputs.nixpkgs.follows = "fedimint-pkgs/nixpkgs"; }; }; - outputs = { self, nixpkgs, nixpkgs-unstable, flake-utils, fedimint-pkgs, fs-dir-cache, android-nixpkgs, flakebox, ... }: + outputs = { self, nixpkgs, nixpkgs-unstable, flake-utils, fedimint-pkgs, fs-dir-cache, cargo-deluxe, android-nixpkgs, flakebox, ... }: flake-utils.lib.eachDefaultSystem (system: let pkgs-unstable = import nixpkgs-unstable { @@ -45,19 +50,17 @@ fs-dir-cache = fs-dir-cache.packages.${system}.default; fastlane = pkgs-unstable.fastlane; convco = pkgs-unstable.convco; - - mprocs = prev.mprocs.overrideAttrs (final: prev: { - patches = prev.patches ++ [ - (builtins.fetchurl { - url = "https://github.com/pvolok/mprocs/pull/88.patch"; - name = "clipboard-fix.patch"; - sha256 = "sha256-9dx1vaEQ6kD66M+vsJLIq1FK+nEObuXSi3cmpSZuQWk="; - }) - ]; + cargo-deluxe = cargo-deluxe.packages.${system}.default; + snappy = prev.snappy.overrideAttrs (f: p: rec { + version = "1.2.1"; + src = prev.fetchFromGitHub { + owner = "google"; + repo = "snappy"; + rev = version; + hash = "sha256-IzKzrMDjh+Weor+OrKdX62cAKYTdDXgldxCgNE2/8vk="; + }; }); - }) - fedimint-pkgs.overlays.all ]; }; @@ -171,7 +174,7 @@ }; toolchainArgs = let llvmPackages = pkgs.llvmPackages_11; in { - extraRustFlags = "--cfg tokio_unstable -Z threads=0 --cfg=curve25519_dalek_backend=\"serial\""; + extraRustFlags = "--cfg tokio_unstable -Z threads=5 --cfg=curve25519_dalek_backend=\"serial\" -Csymbol-mangling-version=v0"; components = [ "rustc" @@ -317,12 +320,10 @@ alias create-avd="avdmanager create avd --force --name phone --package 'system-images;android-32;google_apis;arm64-v8a' --path $PWD/avd"; alias emulator="emulator -avd phone" - # hijack cargo for our evil purposes - export CARGO_ORIG_BIN="$(${pkgs.which}/bin/which cargo)" export REPO_ROOT="$(git rev-parse --show-toplevel)" - export PATH="''${REPO_ROOT}/nix/cargo-wrapper/:$PATH" export RUSTC_WRAPPER=${pkgs.sccache}/bin/sccache export CARGO_BUILD_TARGET_DIR="''${CARGO_BUILD_TARGET_DIR:-''${REPO_ROOT}/target-nix}" + export UPSTREAM_FEDIMINTD_NIX_PKG=${fedimint-pkgs.packages.${system}.fedimintd} # this is where we publish the android bridge package so the react native app # can find it as a local maven dependency diff --git a/justfile.fedi b/justfile.fedi index 3f9a453..5019008 100644 --- a/justfile.fedi +++ b/justfile.fedi @@ -115,6 +115,10 @@ lint-ui: test-ui: pushd ./ui && yarn test && popd +# run UI tests +clean-ui: + @./scripts/ui/clean-ui.sh + fedimint-cli REF REV *ARGS: @nix run "git+https://github.com/fedimint/fedimint.git?ref={{REF}}&rev={{REV}}#ci.fedimint-cli" -- {{ARGS}} diff --git a/modules/stability-pool/server/src/db.rs b/modules/stability-pool/server/src/db.rs index 1f914f4..199d34b 100644 --- a/modules/stability-pool/server/src/db.rs +++ b/modules/stability-pool/server/src/db.rs @@ -1,7 +1,7 @@ use std::collections::BTreeMap; use std::time::SystemTime; -use fedimint_core::db::{DatabaseTransaction, IDatabaseTransactionOpsCoreTyped}; +use fedimint_core::db::{IDatabaseTransactionOpsCoreTyped, MigrationContext}; use fedimint_core::encoding::{Decodable, Encodable}; use fedimint_core::{impl_db_lookup, impl_db_record, Amount, PeerId, TransactionId}; use secp256k1_zkp::PublicKey; @@ -233,7 +233,8 @@ impl_db_lookup!( ); /// Migrate DB from version 1 to version 2 by wiping everything -pub async fn migrate_to_v2(dbtx: &mut DatabaseTransaction<'_>) -> Result<(), anyhow::Error> { +pub async fn migrate_to_v2(mut ctx: MigrationContext<'_>) -> Result<(), anyhow::Error> { + let mut dbtx = ctx.dbtx(); dbtx.remove_by_prefix(&IdleBalanceKeyPrefix).await; dbtx.remove_by_prefix(&StagedSeeksKeyPrefix).await; dbtx.remove_by_prefix(&StagedProvidesKeyPrefix).await; diff --git a/modules/stability-pool/server/src/lib.rs b/modules/stability-pool/server/src/lib.rs index 15bf857..18372b7 100644 --- a/modules/stability-pool/server/src/lib.rs +++ b/modules/stability-pool/server/src/lib.rs @@ -175,7 +175,7 @@ impl ServerModuleInit for StabilityPoolInit { ) -> BTreeMap { let mut migrations = BTreeMap::::default(); - migrations.insert(DatabaseVersion(1), |dbtx| migrate_to_v2(dbtx).boxed()); + migrations.insert(DatabaseVersion(1), |ctx| migrate_to_v2(ctx).boxed()); migrations } } diff --git a/nix/flakebox.nix b/nix/flakebox.nix index a4fcc0f..50d2284 100644 --- a/nix/flakebox.nix +++ b/nix/flakebox.nix @@ -61,59 +61,43 @@ let }; commonEnvsShellRocksdbLink = let - target_underscores = lib.strings.replaceStrings [ "-" ] [ "_" ] pkgs.stdenv.buildPlatform.config; + build_arch_underscores = lib.strings.replaceStrings [ "-" ] [ "_" ] pkgs.stdenv.buildPlatform.config; in { - ROCKSDB_STATIC = "true"; - ROCKSDB_LIB_DIR = "${pkgs.rocksdb}/lib/"; - SNAPPY_LIB_DIR = "${pkgs.pkgsStatic.snappy}/lib/"; - SQLITE3_STATIC = "true"; - SQLITE3_LIB_DIR = "${pkgs.pkgsStatic.sqlite.out}/lib/"; - SQLCIPHER_STATIC = "true"; - SQLCIPHER_LIB_DIR = "${pkgs.pkgsStatic.sqlcipher}/lib/"; - - "ROCKSDB_${target_underscores}_STATIC" = "true"; - "ROCKSDB_${target_underscores}_LIB_DIR" = "${pkgs.rocksdb}/lib/"; - "SQLITE3_${target_underscores}_STATIC" = "true"; - "SQLITE3_${target_underscores}_LIB_DIR" = "${pkgs.pkgsStatic.sqlite}/lib/"; - "SQLCIPHER_${target_underscores}_STATIC" = "true"; - "SQLCIPHER_${target_underscores}_LIB_DIR" = "${pkgs.pkgsStatic.sqlcipher}/lib/"; - "SNAPPY_${target_underscores}_LIB_DIR" = "${pkgs.pkgsStatic.snappy}/lib/"; - } // pkgs.lib.optionalAttrs (!pkgs.stdenv.isDarwin) { - # macos can't static libraries - SNAPPY_STATIC = "true"; - "SNAPPY_${target_underscores}_STATIC" = "true"; + # for cargo-deluxe + CARGO_TARGET_SPECIFIC_ENVS = builtins.concatStringsSep "," [ + "ROCKSDB_target_STATIC" + "ROCKSDB_target_LIB_DIR" + "SNAPPY_target_STATIC" + "SNAPPY_target_LIB_DIR" + "SNAPPY_target_COMPILE" + "SQLITE3_target_STATIC" + "SQLITE3_target_LIB_DIR" + "SQLCIPHER_target_STATIC" + "SQLCIPHER_target_LIB_DIR" + ]; } // pkgs.lib.optionalAttrs (!pkgs.stdenv.isDarwin) { - # TODO: could we used the android-nixpkgs toolchain instead of another one? - # BROKEN: seems to produce binaries that crash; needs investigation - # ROCKSDB_aarch64_linux_android_STATIC = "true"; - # SNAPPY_aarch64_linux_android_STATIC = "true"; - # ROCKSDB_aarch64_linux_android_LIB_DIR = "${pkgs-unstable.pkgsCross.aarch64-android-prebuilt.rocksdb}/lib/"; - # SNAPPY_aarch64_linux_android_LIB_DIR = "${pkgs-unstable.pkgsCross.aarch64-android-prebuilt.pkgsStatic.snappy}/lib/"; - - # BROKEN - # error: "No timer implementation for this platform" - # ROCKSDB_armv7_linux_androideabi_STATIC = "true"; - # SNAPPY_armv7_linux_androideabi_STATIC = "true"; - # ROCKSDB_armv7_linux_androideabi_LIB_DIR = "${pkgs-unstable.pkgsCross.armv7a-android-prebuilt.rocksdb}/lib/"; - # SNAPPY_armv7_linux_androideabi_LIB_DIR = "${pkgs-unstable.pkgsCross.armv7a-android-prebuilt.pkgsStatic.snappy}/lib/"; - - # x86-64-linux-android doesn't have a toolchain in nixpkgs + "ROCKSDB_${build_arch_underscores}_STATIC" = "true"; + "ROCKSDB_${build_arch_underscores}_LIB_DIR" = "${pkgs.rocksdb}/lib/"; + + # does not produce static lib in most versions + "SNAPPY_${build_arch_underscores}_STATIC" = "true"; + "SNAPPY_${build_arch_underscores}_LIB_DIR" = "${pkgs.pkgsStatic.snappy}/lib/"; + # "SNAPPY_${build_arch_underscores}_COMPILE" = "true"; + + + "SQLITE3_${build_arch_underscores}_STATIC" = "true"; + "SQLITE3_${build_arch_underscores}_LIB_DIR" = "${pkgs.pkgsStatic.sqlite.out}/lib/"; + + "SQLCIPHER_${build_arch_underscores}_LIB_DIR" = "${pkgs.pkgsStatic.sqlcipher}/lib/"; + "SQLCIPHER_${build_arch_underscores}_STATIC" = "true"; } // pkgs.lib.optionalAttrs pkgs.stdenv.isDarwin { - # broken: fails to compile with: - # `linux-headers-android-common> sh: line 1: gcc: command not found` - # ROCKSDB_aarch64_linux_android_STATIC = "true"; - # SNAPPY_aarch64_linux_android_STATIC = "true"; - # ROCKSDB_aarch64_linux_android_LIB_DIR = "${pkgs-unstable.pkgsCross.aarch64-android.rocksdb}/lib/"; - # SNAPPY_aarch64_linux_android_LIB_DIR = "${pkgs-unstable.pkgsCross.aarch64-android.pkgsStatic.snappy}/lib/"; - - # requires downloading Xcode manually and adding to /nix/store - # then running with `env NIXPKGS_ALLOW_UNFREE=1 nix develop -L --impure` - # maybe we could live with it? - # ROCKSDB_aarch64_apple_ios_STATIC = "true"; - # SNAPPY_aarch64_apple_ios_STATIC = "true"; - # ROCKSDB_aarch64_apple_ios_LIB_DIR = "${pkgs-unstable.pkgsCross.iphone64.rocksdb}/lib/"; - # SNAPPY_aarch64_apple_ios_LIB_DIR = "${pkgs-unstable.pkgsCross.iphone64.pkgsStatic.snappy}/lib/"; + # tons of problems, just compile + # "SNAPPY_${build_arch_underscores}_LIB_DIR" = "${pkgs.snappy}/lib/"; + "SNAPPY_${build_arch_underscores}_COMPILE" = "true"; + + "SQLITE3_${build_arch_underscores}_LIB_DIR" = "${pkgs.sqlite.out}/lib/"; + "SQLCIPHER_${build_arch_underscores}_LIB_DIR" = "${pkgs.sqlcipher}/lib/"; }; commonArgs = @@ -123,6 +107,12 @@ let moreutils-ts = pkgs.writeShellScriptBin "ts" "exec ${pkgs.moreutils}/bin/ts \"$@\""; in { + packages = [ + # flakebox adds toolchains via `packages`, which seems to always take precedence + # `nativeBuildInputs` in `mkShell`, so we need to add it here as well. + (lib.hiPrio pkgs.cargo-deluxe) + ]; + buildInputs = builtins.attrValues { inherit (pkgs) openssl; @@ -137,6 +127,8 @@ let inherit (pkgs) perl; inherit moreutils-ts; }) ++ [ + (lib.hiPrio pkgs.cargo-deluxe) + # add a command that can be used to lower both CPU and IO priority # of a command to help make it more friendly to other things # potentially sharing the CI or dev machine @@ -313,6 +305,13 @@ rec { ]; }; + fedi-ffi = fediBuildPackageGroup { + pname = "fedi-ffi"; + packages = [ + "fedi-ffi" + ]; + }; + testStabilityPool = craneLib.buildCommand (commonTestArgs // { pname = "fedi-test-stability-pool"; cargoArtifacts = workspaceBuild; @@ -321,7 +320,7 @@ rec { cmd = '' patchShebangs ./scripts - export FM_CARGO_DENY_COMPILATION=1 + export CARGO_DENY_COMPILATION=1 # check that all expected binaries are available for i in lnd lightningd gatewayd esplora electrs bitcoind ; do @@ -345,7 +344,7 @@ rec { cmd = '' patchShebangs ./scripts - export FM_CARGO_DENY_COMPILATION=1 + export CARGO_DENY_COMPILATION=1 # check that all expected binaries are available for i in lnd lightningd gatewayd esplora electrs bitcoind ; do @@ -365,11 +364,11 @@ rec { cmd = '' patchShebangs ./scripts - export FM_CARGO_DENY_COMPILATION=1 export HOME=/tmp export FM_TEST_CI_ALL_TIMES=${builtins.toString 1} export FM_TEST_CI_ALL_DISABLE_ETA=1 + export UPSTREAM_FEDIMINTD_NIX_PKG=${fedimint-pkgs.packages.${system}.fedimintd} ./scripts/test-ci-all.sh ''; }); @@ -388,6 +387,9 @@ rec { fedi-fedimint-pkgs pkgs.bash pkgs.coreutils + pkgs.busybox + pkgs.curl + pkgs.rsync ]; config = { Cmd = [ ]; # entrypoint will handle empty vs non-empty cmd @@ -410,7 +412,7 @@ rec { fedi-fedimint-cli = pkgs.dockerTools.buildLayeredImage { name = "fedi-fedimint-cli"; - contents = [ fedi-fedimint-cli pkgs.bash pkgs.coreutils ]; + contents = [ fedi-fedimint-cli pkgs.bash pkgs.coreutils pkgs.busybox pkgs.curl pkgs.rsync ]; config = { Cmd = [ "${fedimint-pkgs}/bin/fedimint-cli" diff --git a/scripts/bridge/build-bridge-android.sh b/scripts/bridge/build-bridge-android.sh index 0c9031c..f60c6d5 100755 --- a/scripts/bridge/build-bridge-android.sh +++ b/scripts/bridge/build-bridge-android.sh @@ -25,7 +25,7 @@ echo "Building android bridge for targets: ${TARGETS[*]}" # build binaries for each supported target for target in "${TARGETS[@]}"; do echo "Building android bridge for $target" - cargo build --target-dir "${CARGO_BUILD_TARGET_DIR}/pkg/fedi-ffi" ${CARGO_PROFILE:+--profile ${CARGO_PROFILE}} -p fedi-ffi --target $target + cargo build --target-dir "${CARGO_BUILD_TARGET_DIR}" ${CARGO_PROFILE:+--profile ${CARGO_PROFILE}} -p fedi-ffi --target $target if [ "${target:-}" == "aarch64-linux-android" ]; then JNILIBS_PATH=arm64-v8a @@ -44,9 +44,9 @@ done # build android lib with ffi-bindgen inside nix cd $BRIDGE_ROOT/fedi-ffi # note: using '--target-dir' or otherwise this build will completely invalidate previous ones already in the ./target -cargo run --target-dir "${CARGO_BUILD_TARGET_DIR}/pkg/fedi-ffi/ffi-bindgen-run" --package ffi-bindgen -- generate --language kotlin --out-dir $BRIDGE_ROOT/fedi-android/lib/src/main/kotlin "$BRIDGE_ROOT/fedi-ffi/src/fedi.udl" +cargo run --target-dir "${CARGO_BUILD_TARGET_DIR}/ffi-bindgen-run" --package ffi-bindgen -- generate --language kotlin --out-dir $BRIDGE_ROOT/fedi-android/lib/src/main/kotlin "$BRIDGE_ROOT/fedi-ffi/src/fedi.udl" # publish android package to a local maven repository so the app can locate it cd $BRIDGE_ROOT/fedi-android mkdir -p "$ANDROID_BRIDGE_ARTIFACTS" -./gradlew publishMavenPublicationToFediAndroidRepository \ No newline at end of file +./gradlew publishMavenPublicationToFediAndroidRepository diff --git a/scripts/bridge/build-bridge-ios.sh b/scripts/bridge/build-bridge-ios.sh index 27b178b..b1866ac 100755 --- a/scripts/bridge/build-bridge-ios.sh +++ b/scripts/bridge/build-bridge-ios.sh @@ -33,7 +33,7 @@ rm -f $(find $CARGO_BUILD_TARGET_DIR/pkg/ffi-bindgen -name libfediffi.a | grep - # build binaries for each supported target for target in "${TARGETS[@]}"; do - cargo build --target-dir "${CARGO_BUILD_TARGET_DIR}/pkg/fedi-ffi" --package fedi-ffi ${CARGO_PROFILE:+--profile ${CARGO_PROFILE}} --target $target $CARGO_FLAGS + cargo build --target-dir "${CARGO_BUILD_TARGET_DIR}" --package fedi-ffi ${CARGO_PROFILE:+--profile ${CARGO_PROFILE}} --target $target $CARGO_FLAGS done # make sure build artifacts are available to the fedi-swift Xcode package diff --git a/scripts/bridge/ts-bindgen.sh b/scripts/bridge/ts-bindgen.sh index 6be53b4..2c3d3ef 100755 --- a/scripts/bridge/ts-bindgen.sh +++ b/scripts/bridge/ts-bindgen.sh @@ -15,4 +15,4 @@ rm -f $BRIDGE_ROOT/fedi-ffi/target/bindings/*.ts cargo test -- export_bindings # concat all .ts files, remove imports, remove comments, add bindings.ts.inc at top cat $BRIDGE_ROOT/fedi-ffi/target/bindings/*.ts | sed '/^import /d; s://.*$::' | cat $EXPORT_FILE.inc - > $EXPORT_FILE -prettier --write $EXPORT_FILE +prettier --no-config --write $EXPORT_FILE diff --git a/scripts/build.sh b/scripts/build.sh index 808da4a..c6fba32 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -1,30 +1,6 @@ #!/usr/bin/env bash -echo "Run with 'source ./scripts/build.sh [fed_size] [dir]" - -# allow for overriding arguments -export FM_FED_SIZE=${1:-4} - -# If $TMP contains '/nix-shell.' it is already unique to the -# nix shell instance, and appending more characters to it is -# pointless. It only gets us closer to the 108 character limit -# for named unix sockets (https://stackoverflow.com/a/34833072), -# so let's not do it. - -if [[ "${TMP:-}" == *"/nix-shell."* ]]; then - FM_TEST_DIR="${2-$TMP}/fm-$(LC_ALL=C tr -dc A-Za-z0-9 /dev/null && pwd )" @@ -32,16 +8,3 @@ cd $SRC_DIR || exit 1 # Compile binaries in a way that nix can cache cargo build --profile "${CARGO_PROFILE}" - -# Function for killing processes stored in FM_PID_FILE in reverse-order they were created in -function kill_fedimint_processes { - echo "Killing fedimint processes" - PIDS=$(cat $FM_PID_FILE | sed '1!G;h;$!d') # sed reverses order - if [ -n "$PIDS" ] - then - kill $PIDS 2>/dev/null - fi - rm -f $FM_PID_FILE -} - -trap kill_fedimint_processes EXIT diff --git a/scripts/ci/prepare-apk.js b/scripts/ci/prepare-apk.js new file mode 100644 index 0000000..8493f40 --- /dev/null +++ b/scripts/ci/prepare-apk.js @@ -0,0 +1,62 @@ +const fs = require('fs'); +module.exports = async ({ github, context, core }) => { + const { RELEASE_ID, SOURCE_FEDI_ORG, SOURCE_FEDI_REPO } = process.env; + + console.log('Starting APK preparation process.'); + + // Fetch release assets from the source repo + console.log(`Fetching release assets from the source repository`); + const { data: assets } = await github.rest.repos.listReleaseAssets({ + owner: SOURCE_FEDI_ORG, + repo: SOURCE_FEDI_REPO, + release_id: RELEASE_ID, + }); + + // Find the APK file + const apkAsset = assets.find((asset) => asset.name.endsWith('.apk')); + if (!apkAsset) { + throw new Error('APK file not found'); + } + console.log(`APK file found: ${apkAsset.name}`); + + // Extract version and commit hash + const regex = /app-production-release-(\d+\.\d+\.\d+)-\d+-commit-([0-9a-f]+)\.apk/; + const match = apkAsset.name.match(regex); + if (!match) { + throw new Error('Invalid APK filename format'); + } + + const [, version, commitHash] = match; + console.log(`Extracted version: ${version} and commit hash: ${commitHash} from APK filename`); + const truncatedCommitHash = commitHash.substring(0, 6); + const newFileName = `app-production-release-${version}-${truncatedCommitHash}.apk`; + + // Copy the APK into the directory Vercel expects it to be + console.log('Downloading APK from source repository...'); + const apkBuffer = await github.rest.repos.getReleaseAsset({ + owner: SOURCE_FEDI_ORG, + repo: SOURCE_FEDI_REPO, + asset_id: apkAsset.id, + headers: { + Accept: 'application/octet-stream', + }, + }); + + // Convert ArrayBuffer to Buffer + const buffer = Buffer.from(apkBuffer.data); + + console.log(`Writing APK to ui/apk/${newFileName}...`); + fs.writeFileSync(`ui/apk/${newFileName}`, buffer); + + console.log('Reading and updating APK index.html...'); + const apkIndexHtml = fs.readFileSync('ui/apk/index.html').toString(); + const replacedHtml = apkIndexHtml.replace('{{path_to_apk}}', `./${newFileName}`); + + console.log('Writing updated index.html...'); + fs.writeFileSync('ui/apk/index.html', replacedHtml); + console.log('index.html updated with APK download link:', newFileName); + + // Set output for next steps + core.exportVariable('NEW_VERSION', version); + core.exportVariable('NEW_APK_FILENAME', newFileName); +}; \ No newline at end of file diff --git a/scripts/ci/publish-release.js b/scripts/ci/publish-release.js new file mode 100644 index 0000000..f59a647 --- /dev/null +++ b/scripts/ci/publish-release.js @@ -0,0 +1,43 @@ +module.exports = async ({ github, context, core }) => { + const { PUBLIC_APK_URL, PUBLIC_FEDI_ORG, PUBLIC_FEDI_REPO, NEW_VERSION, NEW_APK_FILENAME } = + process.env; + + const version = NEW_VERSION; + const filename = NEW_APK_FILENAME; + + const newTagName = `v${version}`; + // Drop patch version number for title + const newTitle = `Fedi v${version.split('.').slice(0, 2).join('.')} - APK Download`; + const newDescription = `Download & install Fedi

Download: [${filename}](${PUBLIC_APK_URL})`; + + // Check if a release with the same title exists in the target repo + const { data: releases } = await github.rest.repos.listReleases({ + owner: PUBLIC_FEDI_ORG, + repo: PUBLIC_FEDI_REPO, + }); + + const existingRelease = releases.find((release) => release.name === newTitle); + + if (existingRelease) { + console.log('Existing release found, updating description & version...'); + await github.rest.repos.updateRelease({ + owner: PUBLIC_FEDI_ORG, + repo: PUBLIC_FEDI_REPO, + release_id: existingRelease.id, + tag_name: newTagName, + name: newTitle, + body: newDescription, + }); + console.log('Existing release updated with new APK version.'); + } else { + console.log('No existing release found, creating a new one.'); + await github.rest.repos.createRelease({ + owner: PUBLIC_FEDI_ORG, + repo: PUBLIC_FEDI_REPO, + tag_name: newTagName, + name: newTitle, + body: newDescription, + }); + console.log('New release created with latest APK version.'); + } +}; \ No newline at end of file diff --git a/scripts/ci/vercel-apk.sh b/scripts/ci/vercel-apk.sh new file mode 100755 index 0000000..c8da3c2 --- /dev/null +++ b/scripts/ci/vercel-apk.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash + +set -euo pipefail + +REPO_ROOT=$(git rev-parse --show-toplevel) + +$REPO_ROOT/scripts/enforce-nix.sh + +# Pull Vercel Environment Information +vercel pull --yes --environment=production --token="$VERCEL_TOKEN" + +# Deploy Project Artifacts to Vercel +url=$(vercel deploy --prod --token="$VERCEL_TOKEN" --cwd $REPO_ROOT/ui/apk) +echo "url=$url" >> "$GITHUB_OUTPUT" diff --git a/scripts/test-bridge-current.inner.sh b/scripts/test-bridge-current.inner.sh index 0283afd..761a244 100755 --- a/scripts/test-bridge-current.inner.sh +++ b/scripts/test-bridge-current.inner.sh @@ -11,7 +11,7 @@ export FEDI_SOCIAL_RECOVERY_MODULE_ENABLE=1 export RUST_BACKTRACE=full # fedi packages -source scripts/build.sh "" +source scripts/test-common.sh "" echo "Running in temporary directory $FM_TEST_DIR" export FM_ADMIN_PASSWORD=p diff --git a/scripts/test-bridge-current.sh b/scripts/test-bridge-current.sh index 88ac307..3613ab0 100755 --- a/scripts/test-bridge-current.sh +++ b/scripts/test-bridge-current.sh @@ -3,7 +3,12 @@ set -euo pipefail source scripts/common.sh -export PATH="${CARGO_BIN_DIR}:$PATH" +# whichever fedimint binary appears in path first will be used +if [ -n "${USE_UPSTREAM_FEDIMINTD:-}" ]; then + export PATH="${UPSTREAM_FEDIMINTD_NIX_PKG}/bin:${CARGO_BIN_DIR}:$PATH" +else + export PATH="${CARGO_BIN_DIR}:$PATH" +fi own_dir="$(dirname "${BASH_SOURCE[0]}")" source "${own_dir}/test-bridge-current.inner.sh" "$@" diff --git a/scripts/test-ci-all.sh b/scripts/test-ci-all.sh index fb8a066..5a49a87 100755 --- a/scripts/test-ci-all.sh +++ b/scripts/test-ci-all.sh @@ -34,7 +34,7 @@ runLowPrio cargo nextest run --no-run ${CARGO_PROFILE:+--cargo-profile ${CARGO_P # let us enforce it, we need to go behind its back. We put a fake 'rustc' # in the PATH. # If you really need to break this rule, ping dpc -export FM_CARGO_DENY_COMPILATION=1 +export CARGO_DENY_COMPILATION=1 function rust_unit_tests() { # unit tests don't use binaries from old versions, so there's no need to run for backwards-compatibility tests @@ -54,6 +54,11 @@ function test_bridge_current() { } export -f test_bridge_current +function test_bridge_current_use_upstream_fedimintd() { + USE_UPSTREAM_FEDIMINTD=1 fm-run-test "${FUNCNAME[0]}" ./scripts/test-bridge-current.sh +} +export -f test_bridge_current_use_upstream_fedimintd + tests_to_run_in_parallel=() for _ in $(seq "${FM_TEST_CI_ALL_TIMES:-1}"); do # NOTE: try to keep the slowest tests first, except 'always_success_test', @@ -61,6 +66,7 @@ for _ in $(seq "${FM_TEST_CI_ALL_TIMES:-1}"); do tests_to_run_in_parallel+=( test_stability_pool test_bridge_current + test_bridge_current_use_upstream_fedimintd ) done diff --git a/scripts/test-common.sh b/scripts/test-common.sh new file mode 100644 index 0000000..f041f29 --- /dev/null +++ b/scripts/test-common.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash + +echo "Run with 'source ./scripts/build.sh [fed_size] [dir]" + +# allow for overriding arguments +export FM_FED_SIZE=${1:-4} + +# If $TMP contains '/nix-shell.' it is already unique to the +# nix shell instance, and appending more characters to it is +# pointless. It only gets us closer to the 108 character limit +# for named unix sockets (https://stackoverflow.com/a/34833072), +# so let's not do it. + +if [[ "${TMP:-}" == *"/nix-shell."* ]]; then + FM_TEST_DIR="${2-$TMP}/fm-$(LC_ALL=C tr -dc A-Za-z0-9 /dev/null && pwd )" +cd $SRC_DIR || exit 1 + +# Function for killing processes stored in FM_PID_FILE in reverse-order they were created in +function kill_fedimint_processes { + echo "Killing fedimint processes" + PIDS=$(cat $FM_PID_FILE | sed '1!G;h;$!d') # sed reverses order + if [ -n "$PIDS" ] + then + kill $PIDS 2>/dev/null + fi + rm -f $FM_PID_FILE +} + +trap kill_fedimint_processes EXIT diff --git a/scripts/test-stability-pool.sh b/scripts/test-stability-pool.sh index dc8246d..4bd6013 100755 --- a/scripts/test-stability-pool.sh +++ b/scripts/test-stability-pool.sh @@ -4,6 +4,7 @@ set -euo pipefail source scripts/common.sh +source scripts/test-common.sh export RUST_LOG="${RUST_LOG:-info}" export RUST_BACKTRACE=1 @@ -12,8 +13,6 @@ export FEDI_STABILITY_POOL_MODULE_ENABLE=1 export USE_STABILITY_POOL_TEST_PARAMS=1 export FEDI_STABILITY_POOL_MODULE_TEST_PARAMS=1 -source ./scripts/build.sh "" - # needs the compiled binaries in the PATH PATH="$CARGO_BIN_DIR:$PATH" which fedimintd diff --git a/scripts/ui/clean-ui.sh b/scripts/ui/clean-ui.sh new file mode 100755 index 0000000..d62eb83 --- /dev/null +++ b/scripts/ui/clean-ui.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash + +set -e +REPO_ROOT=$(git rev-parse --show-toplevel) +"$REPO_ROOT/scripts/enforce-nix.sh" + +clean_node_modules() { + echo "Cleaning node_modules folders..." + rm -rf "$REPO_ROOT/ui/node_modules" + rm -rf "$REPO_ROOT/ui/web/node_modules" + rm -rf "$REPO_ROOT/ui/native/node_modules" + rm -rf "$REPO_ROOT/ui/common/node_modules" + rm -rf "$REPO_ROOT/ui/injections/node_modules" +} + +delete_xcode_derived_data() { + echo "Deleting DerivedData for a clean build directory..." + if [[ -n "$CI" ]]; then + rm -rf /Users/runner/Library/Developer/Xcode/DerivedData + else + rm -rf ~/Library/Developer/Xcode/DerivedData + fi +} + +clean_ios() { + echo "Cleaning iOS build files..." + rm -rf "$REPO_ROOT/ui/native/ios/build" + rm -rf "$REPO_ROOT/ui/native/ios/Pods" + delete_xcode_derived_data +} + +clean_android() { + echo "Cleaning Android build files..." + rm -rf "$REPO_ROOT/ui/native/android/build" + rm -rf "$REPO_ROOT/ui/native/android/app/build" +} + +clean_all() { + clean_node_modules + clean_ios + clean_android +} + +while true; do + echo -e "\nUI Cleaning Utils: Select an option:" + echo "i - delete iOS build files only (ios/build, ios/Pods, and DerivedData)" + echo "x - delete Xcode DerivedData only" + echo "a - delete Android build files only" + echo "n - delete all node_modules folders only" + echo "f - full clean (all of the above)" + echo "b - back" + + read -rsn1 input + + case $input in + i) + echo "Cleaning iOS build files..." + clean_ios + exit 0 + ;; + x) + echo "Deleting Xcode DerivedData..." + delete_xcode_derived_data + exit 0 + ;; + a) + echo "Cleaning Android build files..." + clean_android + exit 0 + ;; + n) + echo "Cleaning node_modules folders..." + clean_node_modules + exit 0 + ;; + f) + echo "Cleaning all /ui build files..." + clean_all + exit 0 + ;; + b) + echo "No clean action taken." + exit 0 + ;; + *) + echo "Invalid option. Try again." + ;; + esac +done diff --git a/scripts/ui/dev-ui-utils.sh b/scripts/ui/dev-ui-utils.sh index a7a6445..b1cb5bd 100755 --- a/scripts/ui/dev-ui-utils.sh +++ b/scripts/ui/dev-ui-utils.sh @@ -11,7 +11,7 @@ else fi while true; do - echo "Select an option:" + echo -e "\nDev Utils: Select an option:" echo "L - run the linter for all /ui code" echo "T - run tests for all /ui code" echo "U - run linter + tests for all /ui code" @@ -23,6 +23,9 @@ while true; do echo "B - rebuild bridge & reinstall apps (both android + ios)" echo "w - rebuild wasm" echo "t - rebuild Typescript bindings" + echo "c - clean UI files" + echo "n - reinstall node_modules" + echo "p - reinstall pods" echo "q - quit" read -rsn1 input @@ -85,6 +88,17 @@ while true; do echo "Building Rust-Typescript bindings" $REPO_ROOT/scripts/bridge/ts-bindgen.sh ;; + c) + $REPO_ROOT/scripts/ui/clean-ui.sh + ;; + n) + echo "Reinstalling node_modules" + pushd $REPO_ROOT/ui && yarn install && popd + ;; + p) + echo "Reinstalling pods" + nix develop .#xcode -c $REPO_ROOT/scripts/ui/install-ios-deps.sh + ;; q) echo "Exiting." exit 0 diff --git a/ui/.gitignore b/ui/.gitignore index b84d88d..a9f3fe4 100644 --- a/ui/.gitignore +++ b/ui/.gitignore @@ -8,6 +8,8 @@ yarn-error.log tsconfig.tsbuildinfo .turbo dist +**/next-env.d.ts + # watchman .watchman-cookie-* \ No newline at end of file diff --git a/ui/apk/index.html b/ui/apk/index.html new file mode 100644 index 0000000..a40a710 --- /dev/null +++ b/ui/apk/index.html @@ -0,0 +1,19 @@ + + + + + + + Fedi APK + + + + + + + + diff --git a/ui/common/assets/svgs/alert-warning-triangle.svg b/ui/common/assets/svgs/alert-warning-triangle.svg new file mode 100644 index 0000000..6b4aa29 --- /dev/null +++ b/ui/common/assets/svgs/alert-warning-triangle.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/ui/common/assets/svgs/download.svg b/ui/common/assets/svgs/download.svg new file mode 100644 index 0000000..ad9de59 --- /dev/null +++ b/ui/common/assets/svgs/download.svg @@ -0,0 +1 @@ + diff --git a/ui/common/assets/svgs/file.svg b/ui/common/assets/svgs/file.svg new file mode 100644 index 0000000..726e9a5 --- /dev/null +++ b/ui/common/assets/svgs/file.svg @@ -0,0 +1 @@ + diff --git a/ui/common/assets/svgs/image-off.svg b/ui/common/assets/svgs/image-off.svg new file mode 100644 index 0000000..167c091 --- /dev/null +++ b/ui/common/assets/svgs/image-off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/common/assets/svgs/image.svg b/ui/common/assets/svgs/image.svg new file mode 100644 index 0000000..1bf4597 --- /dev/null +++ b/ui/common/assets/svgs/image.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/common/assets/svgs/online-dot.svg b/ui/common/assets/svgs/online-dot.svg new file mode 100644 index 0000000..b3669a7 --- /dev/null +++ b/ui/common/assets/svgs/online-dot.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/ui/common/assets/svgs/trash.svg b/ui/common/assets/svgs/trash.svg new file mode 100644 index 0000000..d19f1e0 --- /dev/null +++ b/ui/common/assets/svgs/trash.svg @@ -0,0 +1 @@ + diff --git a/ui/common/assets/svgs/video-off.svg b/ui/common/assets/svgs/video-off.svg new file mode 100644 index 0000000..b4e6529 --- /dev/null +++ b/ui/common/assets/svgs/video-off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/common/hooks/chat.ts b/ui/common/hooks/chat.ts index e035e5c..9d51ed6 100644 --- a/ui/common/hooks/chat.ts +++ b/ui/common/hooks/chat.ts @@ -7,7 +7,7 @@ import type { ChatMember, Sats } from '@fedi/common/types' import { INVALID_NAME_PLACEHOLDER } from '../constants/matrix' import { configureMatrixPushNotifications, - previewDefaultGroupChats, + previewAllDefaultChats, selectActiveFederation, selectActiveFederationId, selectAuthenticatedMember, @@ -430,7 +430,7 @@ export const useDisplayNameForm = (t: TFunction, fedimint?: FedimintBridge) => { // matrix client when registering for the first time await dispatch(startMatrixClient({ fedimint })) // TODO: find a better place for this action - dispatch(previewDefaultGroupChats()) + dispatch(previewAllDefaultChats()) } await dispatch( setMatrixDisplayName({ displayName: username }), diff --git a/ui/common/hooks/transactions.ts b/ui/common/hooks/transactions.ts index 53d44f6..91a7584 100644 --- a/ui/common/hooks/transactions.ts +++ b/ui/common/hooks/transactions.ts @@ -25,7 +25,7 @@ import { selectStabilityTransactionHistory, selectTransactionHistory, } from '../redux/transactions' -import { Federation, MSats, Sats, Transaction } from '../types' +import { LoadedFederation, MSats, Sats, Transaction } from '../types' import { RpcFeeDetails } from '../types/bindings' import amountUtils from '../utils/AmountUtils' import { @@ -220,7 +220,7 @@ export function useExportTransactions(fedimint: FedimintBridge) { const exportTransactions = useCallback( async ( - federation: Federation, + federation: LoadedFederation, ): Promise< | { success: true; uri: string; fileName: string } | { success: false; message: string } @@ -454,9 +454,7 @@ export function useFeeDisplayUtils(t: TFunction) { formattedAmount: `${formattedFederationFee} (${formattedFederationFeeSecondary})`, }, { - label: stabilityPoolAverageFeeRate - ? `${t('phrases.yearly-fee')}*` - : t('words.fees'), + label: `${t('phrases.yearly-fee')}*`, formattedAmount: typeof stabilityPoolAverageFeeRate === 'number' ? `${formattedFeeAverage}%` diff --git a/ui/common/localization/en/common.json b/ui/common/localization/en/common.json index 79e0848..31f1d20 100644 --- a/ui/common/localization/en/common.json +++ b/ui/common/localization/en/common.json @@ -28,6 +28,8 @@ "deposit": "Deposit", "details": "Details", "done": "Done", + "download": "Download", + "edit": "Edit", "email": "Email", "enjoy": "Enjoy", "error": "Error", @@ -54,6 +56,7 @@ "leave": "Leave", "lightning": "Lightning", "loading": "Loading", + "melt": "Melt", "member": "Member", "members": "Members", "memo": "Memo", @@ -65,15 +68,18 @@ "no": "No", "nostr": "Nostr", "notes": "Notes", + "offline": "Offline", "okay": "Okay", "onchain": "On-chain", "one": "one", + "online": "Online", "optional": "Optional", "paid": "Paid", "pay": "Pay", "payments": "Payments", "pending": "Pending", "people": "People", + "preimage": "Preimage", "public": "Public", "receive": "Receive", "received": "Received", @@ -103,6 +109,7 @@ "transactions": "Transactions", "two": "two", "unknown": "Unknown", + "unstable": "Unstable", "unsupported": "Unsupported", "upload": "Upload", "url": "url", @@ -137,6 +144,7 @@ "copied-member-code": "Copied Fedi member code", "copied-to-clipboard": "Copied to clipboard", "copied-transaction-id": "Copied transaction ID", + "copy-text": "Copy text", "display-currency": "Display currency", "edit-note": "Edit note", "edit-profile": "Edit profile", @@ -182,6 +190,7 @@ "this-group": "this group", "total-fees": "Total Fees", "transaction-id": "Transaction ID", + "type-message": "Type message...", "view-public-federations": "View public communities", "wallet-balance": "Wallet balance: {{balance}} sats", "yearly-fee": "Yearly fee", @@ -200,6 +209,7 @@ "chat-unavailable": "This community does not have a chat server.", "default-groups-must-be-broadcast": "Default groups must be broadcast only", "failed-to-ban-user": "Failed to ban user", + "failed-to-download-file": "Failed to download file to the filesystem", "failed-to-fetch-gateways": "Failed to fetch gateways", "failed-to-fetch-guardian-approval": "Failed to fetch guardian approval", "failed-to-fetch-transactions": "Failed to fetch transactions", @@ -207,11 +217,14 @@ "failed-to-invite-to-group": "Failed to invite user to group", "failed-to-join-federation": "Failed to join community", "failed-to-leave-federation": "Failed to leave community", + "failed-to-load-image": "Failed to load image", "failed-to-load-tos": "Failed to load terms of service", + "failed-to-load-video": "Failed to load video", "failed-to-pay-invoice": "Failed to pay invoice", "failed-to-remove-user": "Failed to remove user", "failed-to-switch-gateways": "Failed to switch gateways", "failed-to-update-notification": "Failed to update notification setting for this chat", + "files-may-not-exceed-20mb": "Files may not exceed 20MB", "get-nostr-pubkey-failed": "Failed to get nostr pub key", "history-render-error": "Encountered an error rendering this item", "insufficient-balance": "Insufficient balance. You only have {{balance}} in your wallet", @@ -227,6 +240,7 @@ "onchain-deposits-disabled": "Onchain deposits have been disabled for this community", "only-group-owners-can-change-name": "Only group owners can change the group name", "please-force-quit-the-app": "An error occurred, please force quit the app and try again", + "please-grant-permission": "Please grant permission to download this file", "please-join-a-federation": "Please join a community before making chat payments", "receive-ecash-failed": "Failed to receive ecash", "receives-have-been-disabled": "Receives have been disabled for this community", @@ -335,6 +349,7 @@ "community-chat": "Community chat", "confirm-add-admin-to-group": "Are you sure you want to add {{username}} as an admin? They will be able to send messages in this group.", "confirm-add-to-group": "Are you sure you want to invite {{username}} to {{roomName}}?", + "confirm-delete-message": "Are you sure you want to delete this message?", "confirm-remove-admin-from-group": "Are you sure you want to remove {{username}} as an admin? They won't be able to send messages in this group but their previously sent messages will remain.", "copied-group-invite-code": "Copied group invite code", "create-a-display-name": "Create a display name", @@ -349,6 +364,7 @@ "enter-display-name": "Enter a display name", "fedi-community": "Fedi Community", "fedi-community-message-preview": "Welcome to Fedi! This channel will keep you up to date on events happening within your Fedi app", + "file-saved": "File saved", "former-member": "Former Member", "go-to-direct-chat": "Go to direct chat", "group-chat": "Group chat", @@ -364,6 +380,7 @@ "leave-chat-confirmation": "Are you sure you want to leave this chat? All message history will be lost.", "leave-group": "Leave group", "leave-group-confirmation": "Are you sure you want to leave the group? All group message history will be lost.", + "message-deleted": "This message was deleted", "member-not-found": "Could not find a member with the username '{{username}}'", "need-registration-description": "Set your display name to be able to send and receive with other users.", "need-registration-title": "Get ready to chat", @@ -391,6 +408,9 @@ "remove-user": "Remove user from group", "removed-admin-from-group": "Removed {{username}} as an admin", "removed-member": "Removed Member", + "saved-to-photo-library": "Saved to your Photos", + "saved-to-pictures": "Saved to Pictures", + "saved-to-movies": "Saved to Movies", "scan-chat-invite": "Scan chat invite", "scan-group-invite": "Scan a group invite", "scan-member-code-notice": "Show QR to share your username", @@ -448,6 +468,9 @@ "federations": { "add-federation": "Add a Community", "camera-access-information": "To join a Community, you will need to allow Fedi access to your camera to scan the Community invite code", + "connection-status-online": "This community is fully active", + "connection-status-offline": "This community is offline. Please reach out to the guardians or community leader for further information.", + "connection-status-unstable": "This community is unstable. Please reach out to the guardians or community leader for further information.", "copied-federation-invite": "Copied community invite link", "enter-federation-code": "Enter a community code", "federation-details": "Community Details", @@ -635,6 +658,7 @@ "balance-not-spendable-offline": "This balance will not be able to be spent until you come back online", "bitcoin-request": "Bitcoin request", "camera-access-information": "To receive money offline, you will need to allow Fedi access to your camera to scan an offline payment code", + "cashu-ecash": "Cashu ecash", "copied-payment-code": "Copied payment request", "create-lightning-request": "Create lightning request", "enable-onchain-deposits": "Enable onchain deposits", diff --git a/ui/common/localization/es/common.json b/ui/common/localization/es/common.json index 0dc081c..93c0373 100644 --- a/ui/common/localization/es/common.json +++ b/ui/common/localization/es/common.json @@ -1,38 +1,40 @@ { "words": { - "accept": "Aceptar", + "accept": "Acepta", "account": "Cuenta", - "actions": "Comportamiento", + "actions": "Acciones", "address": "Dirección", "admin": "Admin", "amount": "Monto", "approved": "Aprobado", - "authorize": "Autorizar", - "backup": "Copia de seguridad", + "authorize": "Autoriza", + "backup": "Realiza una copia de respaldo", "balance": "Saldo", "bitcoin": "Bitcoin", - "cancel": "Cancelar", + "cancel": "Cancela", "canceled": "Cancelado", "chat": "Chat", "community": "Comunidad", - "complete": "Completar", - "confirm": "Confirmar", + "complete": "Completa", + "confirm": "Confirma", "confirmed": "Confirmado", - "continue": "Continuar", + "continue": "Continua", "copy": "Copiar", "currency": "Moneda", - "deposit": "Depósito", + "deposit": "Deposita", "details": "Detalles", "done": "Hecho", "email": "Correo electrónico", - "enjoy": "Disfrutar", + "enjoy": "Disfruta", "error": "Error", - "expired": "Vencido", + "expired": "Expirado", "failed": "Fallido", + "federation": "Comunidad", + "federations": "Comunidades", "fedimint": "Fedimint", "fedimods": "FediMods", "fee": "Tarifa", - "fees": "Honorarios", + "fees": "Tarifas", "from": "De", "general": "General", "group": "Grupo", @@ -41,16 +43,16 @@ "home": "Inicio", "icon": "Icono", "important": "Importante", - "invite": "Invitar", + "invite": "Invita", "invited": "Invitado", - "join": "Unirse", + "join": "Únete", "joined": "Unido", "language": "Idioma", - "leave": "Dejar", + "leave": "Abandona", "lightning": "Lightning", "loading": "Cargando", "member": "Miembro", - "members": "Usuarios", + "members": "Miembros", "memo": "Memo", "message": "Mensaje", "messages": "Mensajes", @@ -60,37 +62,37 @@ "nostr": "Nostr", "notes": "Notas", "okay": "De acuerdo", - "onchain": "Onchain", + "onchain": "On-chain", "one": "uno", "optional": "Opcional", "paid": "Pagado", - "pay": "Pagar", + "pay": "Paga", "payments": "Pagos", - "pending": "pendiente", - "people": "Gente", + "pending": "Pendiente", + "people": "Personas", "public": "Público", - "receive": "Recibir", + "receive": "Recibe", "received": "Recibido", - "receiving": "Recepción", + "receiving": "Recibiendo", "refund": "Reembolso", - "reject": "Rechazar", + "reject": "Rechaza", "rejected": "Rechazado", "remaining": "restante", - "request": "Solicitar", + "request": "Solicita", "required": "Requerido", - "retry": "Reintentar", + "retry": "Reintenta", "sats": "sats", - "save": "Ahorrar", - "scan": "Escanear", + "save": "Guarda", + "scan": "Escanea", "seen": "Visto", - "send": "Enviar", + "send": "Envia", "sent": "Enviado", "settings": "Ajustes", "share": "Compartir", - "skip": "Saltar", + "skip": "Omite por ahora", "status": "Estado", - "stay": "Permanecer", - "submit": "Entregar", + "stay": "Permanece", + "submit": "Envia el reporte", "time": "Hora", "title": "Título", "to": "A", @@ -98,107 +100,108 @@ "two": "dos", "unknown": "Desconocido", "unsupported": "No compatible", - "upload": "Subir", + "upload": "Sube", "url": "URL", "URL": "URL", "username": "Nombre de usuario", - "wallet": "Cartera", - "withdraw": "Retirar", + "wallet": "Billetera", + "withdraw": "Retira", "withdrawal": "Retiro", - "yes": "Sí", - "you": "tú" + "yes": "Si", + "you": "Tú" }, "phrases": { "add-community": "Agregar comunidad", - "add-note": "Agregar nota", - "allow-camera-access": "Permitir acceso a la cámara", + "add-note": "Agrega una nota", + "allow-camera-access": "Permite acceso a la cámara", "app-settings-security": "Ajustes de la aplicación y seguridad", - "app-version": "Versión Fedi: {{version}}", - "back-to-app": "Volver a la aplicación", - "backup-your-wallet": "Realizar copia de seguridad de tu cartera", + "app-version": "Fedi Bravo Versión: {{version}}", + "back-to-app": "Vuelve a la app", + "backup-your-wallet": "Dirección de Bitcoin", "bitcoin-address": "Dirección de Bitcoin", - "bitcoin-address-created": "Dirección Bitcoin creada", - "bitcoin-equivalent": "Equivalente en bitcoins", + "bitcoin-address-created": "Dirección de Bitcoin creada", + "bitcoin-equivalent": "Equivalente en bitcoin", "camera-settings": "Ajustes de la cámara", "changes-may-not-be-saved": "Es posible que los cambios que hayas realizado no se guarden", "changes-saved": "Cambios guardados", "click-for-more-details": "Haz clic para más detalles", "confirm-chat-send": "Confirmar envío de chat", - "connect-to-federation": "Conectarse a la federación", - "copied-bitcoin-address": "Dirección bitcoin copiada", + "connect-to-federation": "Conéctate a la comunidad de prueba", + "copied-bitcoin-address": "Dirección de bitcoin copiada", "copied-ecash-token": "Token de ecash copiado", "copied-lightning-request": "Solicitud de Lightning copiada", "copied-member-code": "Código de usuario de Fedi copiado", "copied-to-clipboard": "Copiado al portapapeles", - "copied-transaction-id": "ID de transacción copiada", - "display-currency": "Mostrar moneda", - "edit-note": "Editar nota", + "copied-transaction-id": "ID de la transacción copiado", + "display-currency": "Moneda de referencia", + "edit-note": "Edita la nota", "edit-profile": "Editar perfil", "email-address": "Dirección de correo electrónico", "expires-in": "Expira en", "failed-to-decode-invoice": "No se pudo decodificar la factura", - "federation-fee": "Cuota de federación", - "fedi-fee": "tarifa fedi", + "federation-fee": "Tarifa de la comunidad", + "fedi-fee": "Tarifa de Fedi", "fee-details": "Detalles de la tarifa", - "generate-invoice": "Generar factura", - "go-back": "Regresar", - "hide-details": "Ocultar detalles", - "hold-to-confirm": "Mantener presionado para confirmar", + "generate-invoice": "Genera una factura", + "go-back": "Regresa", + "hide-details": "Oculta los detalles", + "hold-to-confirm": "Mantén presionado para confirmar", "i-understand": "Entiendo", - "invalid-federation-code": "Código de federación no válido", - "join-another-federation": "Unirse a otra federación", + "invalid-federation-code": "Código de comunidad inválido", + "join-another-federation": "Únete a otra comunidad", "last-seen": "Ultima vez visto", "lets-go": "Vamos", "lightning-address": "Dirección de Lightning", - "lightning-network": "Red relámpago", + "lightning-network": "Red Lightning", "lightning-request": "Solicitud de Lightning", "moderation-tools": "Herramientas de moderación", "network-fee": "Tarifa de red", - "new-member": "Nuevo usuario", + "new-member": "Miembro nuevo", "no-transactions": "No hay transacciones", - "onchain-address": "Dirección onchain", - "open-in-browser": "Abrir en el navegador", + "onchain-address": "Dirección On-chain", + "open-in-browser": "Abre en el navegador", "paste-from-clipboard": "Pegar desde el portapapeles", "payment-received": "Transacción recibida", - "please-confirm": "Por favor, confirma", + "please-confirm": "Por favor confirma", "receive-pending": "Pendiente de recibir", "received-bitcoin": "Bitcoin recibido", "refund-pending": "Reembolso pendiente", - "reload-app": "Recargar la aplicación", - "return-to-home": "Volver a inicio", - "save-changes": "Guardar cambios", + "reload-app": "Recarga la aplicación", + "return-to-home": "Vuelve al inicio", + "save-changes": "Guarda los cambios", "select-federation": "Seleccionar Comunidad", "sent-bitcoin": "Bitcoin enviado", - "start-over": "Comenzar de nuevo", + "start-over": "Comienza de nuevo", "terms-and-conditions": "Términos y condiciones", "this-group": "este grupo", "total-fees": "Tarifas totales", - "transaction-id": "ID de transacción", - "view-public-federations": "Ver federaciones públicas", - "yearly-fee": "Cuota anual", - "you-are-offline": "Actualmente no tienes conexión" + "transaction-id": "ID de la transacción", + "transaction-received": "Transacción recibida", + "view-public-federations": "Ver las comunidades públicas", + "yearly-fee": "Tarifa anual", + "you-are-offline": "Actualmente estás en modo sin conexión" }, "errors": { - "bad-connection": "Su conexión de red puede ser inestable. Inténtelo de nuevo más tarde o conéctese a otra red.", - "browser-feature-not-supported": "Su navegador no soporta esta característica.", - "camera-unavailable": "No se pudo acceder a su cámara.", - "chat-connection-restoring": "La conexión de chat está inestable o se desconectó. Intentando restablecerla...", - "chat-connection-unhealthy": "La conexión de chat está inestable o se desconectó. Vuelve a intentarlo más tarde o reinicia la aplicación...", - "chat-list-render-error": "No se pudo procesar tus chats", - "chat-member-not-found": "No se pudo encontrar este usuario", - "chat-message-render-error": "No se pudo mostrar este mensaje", - "chat-payment-failed": "No se pudo actualizar este pago en el chat", - "chat-unavailable": "Esta federación no tiene un servidor de chat.", + "bad-connection": "Tu conexión de red puede ser inestable. Inténtalo nuevamente más tarde o conéctate a otra red.", + "browser-feature-not-supported": "Tu navegador no soporta esta función", + "camera-unavailable": "No se pudo acceder a tu cámara", + "chat-connection-restoring": "La conexión del chat es inestable o estás sin conexión. Intentando restablecerla.", + "chat-connection-unhealthy": "La conexión del chat es inestable o estás sin conexión. Vuelve a intentarlo más tarde o reinicia la aplicación.", + "chat-list-render-error": "Se encontró un error al procesar tus chats.", + "chat-member-not-found": "No pudimos encontrar este usuario", + "chat-message-render-error": "Se encontró un error al mostrar este mensaje.", + "chat-payment-failed": "No se pudo actualizar el estado este pago por chat", + "chat-unavailable": "Esta comunidad no tiene un servidor de chat.", "default-groups-must-be-broadcast": "Los grupos predeterminados solo deben transmitirse", "failed-to-ban-user": "No se pudo banear a este usuario", - "failed-to-fetch-gateways": "No se pudo buscar Lightning gateways", - "failed-to-fetch-guardian-approval": "No se pudo obtener la aprobación del tutor", - "failed-to-fetch-transactions": "No se pudieron recuperar las transacciones", + "failed-to-fetch-gateways": "No se pudieron recuperar las puertas de enlace Lightning", + "failed-to-fetch-guardian-approval": "No se pudo recuperar la aprobación del Guardian", + "failed-to-fetch-transactions": "No se pudo recuperar el historial de transacciones", "failed-to-generate-invoice": "No se pudo generar la factura", "failed-to-invite-to-group": "No se pudo invitar este usuario al grupo", - "failed-to-join-federation": "No pudiste unirte a la federación", - "failed-to-leave-federation": "No pudo abandonar la federación", - "failed-to-load-tos": "No se pudo cargar los Términos de servicio", + "failed-to-join-federation": "No pudiste unirte a la comunidad", + "failed-to-leave-federation": "No se pudo abandonar la comunidad", + "failed-to-load-tos": "No se pudieron cargar los Términos de Servicio", "failed-to-pay-invoice": "No se pudo pagar la factura", "failed-to-remove-user": "No se pudo eliminar al usuario", "failed-to-switch-gateways": "No se pudo cambiar el Lightning gateway", @@ -209,17 +212,17 @@ "insufficient-balance-send": "Saldo insuficiente para el envío más comisiones. Puedes enviar un máximo de {{sats}} sats", "invalid-amount-max": "El máximo que puedes {{verb}} es {{amount}}", "invalid-amount-min": "El mínimo que puedes {{verb}} es {{amount}}", - "invalid-ecash-token": "Este token de ecash no es válido", - "invalid-federation-code": "Este código de federación no es válido", + "invalid-ecash-token": "Token de ecash no válido", + "invalid-federation-code": "Código de Comunidad no válido", "invalid-group-code": "Este código de grupo no es válido", "invalid-username": "Debe tener 21 caracteres o menos y no puede incluir letras mayúsculas.", - "no-lightning-gateways": "No hay Lightning gateways para este usuario", - "onchain-deposits-disabled": "Los depósitos en cadena han sido deshabilitados para esta federación.", + "no-lightning-gateways": "No se encontraron puertas de enlace Lightning para este usuario", + "onchain-deposits-disabled": "Los depósitos On-chain han sido deshabilitados para esta comunidad.", "only-group-owners-can-change-name": "Solo los creadores del grupo pueden cambiar el nombre del grupo.", "please-force-quit-the-app": "Se ha producido un error. Cierra la aplicación y vuelve a intentarlo", "please-join-a-federation": "Por favor, únete a una federación antes de realizar pagos por chat", - "receive-ecash-failed": "No se pudo recibir dinero en efectivo", - "receives-have-been-disabled": "Se han deshabilitado las recepciones para esta federación", + "receive-ecash-failed": "No se pudo recibir ecash", + "receives-have-been-disabled": "Los depositos han sido deshabilitados para esta comunidad", "recovery-failed": "La recuperación falló, inténtalo de nuevo", "sign-nostr-event-failed": "No se pudo firmar el evento de Nostr", "unknown-error": "Un error desconocido ha ocurrido", @@ -229,173 +232,173 @@ "webln-method-not-supported": "{{method}} no es compatible", "webln-payment-rejected": "Pago rechazado", "webln-payment-request-rejected": "Solicitud de pago rechazada", - "you-have-already-joined": "Ya te has unido a esta federación", + "you-have-already-joined": "Ya te has unido a esta comunidad", "you-have-been-banned": "Has sido baneado de este grupo." }, "feature": { "backup": { - "backup-social-recovery-file": "Hacer una copia de seguridad de tu archivo de recuperación social", - "backup-to-google-drive": "Hacer una copia de seguridad en Google Drive", - "backup-wallet": "Asegurar tu cartera", - "backups-made": "Copias de seguridad realizadas", + "backup-social-recovery-file": "Respalda tu archivo de recuperación social", + "backup-to-google-drive": "Respalda con Google Drive", + "backup-wallet": "Respalda tu billetera", + "backups-made": "Copias de respaldo realizadas", "camera-access-information": "Para crear una copia de seguridad social, permite que Fedi acceda a tu cámara y graba un video de verificación", - "choose-method": "Elegir un método de copia de seguridad", - "choose-method-instructions": "Hacer una copia de seguridad de tu cartera te ayudará a recuperar tu dinero si alguna vez pierdes acceso a Fedi", - "cloud-backup": "Copia de seguridad en la nube", + "choose-method": "Elige un método de respaldo", + "choose-method-instructions": "Crear una copia de respaldo de tu cartera te ayudará a recuperar tu dinero si alguna vez pierdes acceso a Fedi", + "cloud-backup": "Respaldo en la nube", "cloud-backup-instructions": "Crea una copia de seguridad de tu archivo de recuperación en tu almacenamiento en la nube. Además, se te solicitará que realices una copia de seguridad de este archivo junto con dos amigos o familiares.", - "complete-social-backup": "Completar copia de seguridad social", - "confirm-backup-video": "Confirmar video de copia de seguridad", + "complete-social-backup": "Completa el respaldo social", + "confirm-backup-video": "Confirma el video de respaldo", "creating-recovery-file": "Creando archivo de recuperación cifrado...", "export-transactions-to-csv": "Exportar transacciones a CSV", - "file-backup": "Copia de seguridad de archivos", + "file-backup": "Copia de respaldo de archivos", "hold-record-button": "Mantén presionado el botón de grabación y di:", - "personal-backup": "Copia de seguridad personal", + "personal-backup": "Copia de respaldo personal", "personal-backup-instructions": "Escribe 12 palabras de recuperación. Esta opción es para usuarios con experiencia.", - "please-review-backup-video": "Por favor, revisa el video de copia de seguridad", - "press-record-button": "Presione el botón de grabar y diga:", - "record-again": "Grabar de nuevo", - "record-error": "Encontré un error al usar tu cámara", + "please-review-backup-video": "Por favor revisa el video de respaldo", + "press-record-button": "Presiona el botón de grabar y di:", + "record-again": "Graba el video de nuevo", + "record-error": "Se encontró un error al usar tu cámara", "record-video": "Grabar video", "recovery-words": "Palabras de recuperación", "recovery-words-instructions": "Escribe estas palabras en papel con un bolígrafo. Evita almacenarlas digitalmente en tu teléfono.", "review-face-confirmation": "Confirmo que mi rostro se ve con claridad en este video.", "review-voice-confirmation": "Confirmo que mi voz se escucha con claridad en este video", - "save-file": "Guardar el archivo", - "save-your-wallet-backup-file": "Guarda el archivo de la copia de seguridad de tu cartera", - "save-your-wallet-backup-file-again": "Guardar de nuevo en otro lugar", + "save-file": "Guarda el archivo", + "save-your-wallet-backup-file": "Guardar archivo de respaldo de tu billetera", + "save-your-wallet-backup-file-again": "Guarda de nuevo en otro lugar", "save-your-wallet-backup-file-where": "Recomendamos guardar su archivo en diferentes lugares (aplicaciones de mensajería, correo electrónico, almacenamiento en la nube, etc.)", - "social-backup": "Copia de seguridad social", + "social-backup": "Respaldo social", "social-backup-instructions": "Graba un video para verificar tu identidad. Esta opción es recomendable si conoces bien a tus guardianes.", - "social-backup-processing-info-1": "Este archivo incluye tu video de copia de seguridad y detalles sobre tu federación.", + "social-backup-processing-info-1": "Este archivo de recuperacion contiene tu video de respaldo y detalles de tu comunidad", "social-backup-processing-info-2": "Este archivo por sí solo no sirve para recuperar tu dinero, ya que los guardianes verificarán tu identidad durante el proceso de recuperación", "social-backup-video-prompt": "¡Fedi!", - "start-personal-backup": "Comenzar copia de seguridad personal", + "start-personal-backup": "Inicia tu copia de respaldo personal", "start-personal-backup-instructions": "Toma un bolígrafo y papel, y escribe las palabras de recuperación en la pantalla siguiente.", - "start-recording": "Comenzar a grabar", - "start-social-backup": "Comenzar copia de seguridad social", + "start-recording": "Comienza a grabar", + "start-social-backup": "Inicia tu respaldo social", "start-social-backup-instructions": "Una copia de seguridad social te ofrece la opción de respaldar tu cartera de forma segura junto a amigos y familiares, y también cuenta con el respaldo de los guardianes de la federación para ayudarte a recuperarla en caso de que pierdas el acceso.", - "stop-recording": "Detener grabación", - "successfully-backed-up": "Has hecho una copia de seguridad exitosa de tu cartera de Fedi", + "stop-recording": "Detén la grabación", + "successfully-backed-up": "Has creado un copia de respaldo exitosa de tu biilletera de Fedi", "video-file-too-large": "Archivo de video demasiado grande, grabe uno más corto" }, "bug": { "database-attached": "Base de datos adjunta", "description-label": "Describe el problema en detalle", - "description-placeholder": "¿Qué esperabas y qué pasó en su lugar?", + "description-placeholder": "¿Qué esperabas que pasará y que occurió en su lugar?", "email-label": "Email (opcional)", - "info-label": "Incluir la federación y nombre de usuario con este informe", - "log-disclaimer": "Los registros de la aplicación se incluirán con su informe para ayudar a los desarrolladores de Fedi a diagnosticar el problema.", + "info-label": "Incluir la comunidad y nombre de usuario con este informe", + "log-disclaimer": "Los registros de la aplicación se incluirán con tu informe para ayudar a los desarrolladores de Fedi a diagnosticar el problema.", "report-a-bug": "Reportar un error", - "screenshot-label": "Subir capturas de pantalla o grabaciones", + "screenshot-label": "Sube capturas de pantalla o grabaciones", "submit-generating-data": "Generando datos del informe...", "submit-submitting-report": "Enviando informe...", - "submit-uploading-data": "Cargando datos del informe...", + "submit-uploading-data": "Subiendo los datos del reporte....", "success-subtitle": "Ya nos encargaremos de resolverlo", - "success-title": "¡Gracias por tu informe de error!" + "success-title": "¡Gracias por reportar el error!" }, "chat": { "add-a-photo": "Agrega una foto", - "add-admin": "Agregar admin", - "add-an-avatar": "Agregar un avatar", - "add-user-as-an-admin": "Agregar {{username}} como administrador", + "add-admin": "Agrega un admin", + "add-an-avatar": "Agrega un avatar", + "add-user-as-an-admin": "Agrega {{username}} como administrador", "added-admin-to-group": "Se agregó {{username}} como admin", - "admin-settings": "Ajustes de admin", + "admin-settings": "Ajustes del admin", "admin-settings-instructions": "Estos usuarios pueden enviar mensajes en los grupos de difusión.", "archived-chats": "Chats archivados", "ban-user": "Banear usuario del grupo", - "broadcast-admin-instructions": "Estos miembros pueden enviar mensajes en el grupo de difusión.", + "broadcast-admin-instructions": "Estos usuarios pueden enviar mensajes en los grupos de difusión.", "broadcast-admin-settings": "Ajustes de grupos de difusión", - "broadcast-admins": "Administradores de transmisión", - "broadcast-no-message": "Los administradores aún no han transmitido ningún mensaje. Manténganse al tanto.", + "broadcast-admins": "Administradores de difusión", + "broadcast-no-message": "Los administradores aún no han enviado ningún mensaje. Mantente al tanto.", "broadcast-only": "Solo mensajes de difusión", "broadcast-only-notice": "No puedes enviar mensajes en un grupo de difusión", "camera-access-information": "Para unirte a un grupo, necesitarás permitir que Fedi acceda a la cámara de tu dispositivo para escanear un enlace de invitación al grupo", "change-avatar": "Cambiar avatar", - "change-group-name": "Cambiar nombre de grupo", - "change-role": "Cambiar rol", + "change-group-name": "Cambia el nombre del grupo", + "change-role": "Cambia el rol", "change-role-failure": "No se pudo cambiar el rol", - "change-role-success": "Rol cambiado con éxito", + "change-role-success": "Se cambió el rol con éxito", "changing-broadcast-not-supported": "El cambio de la configuración de difusión no está disponible. Te recomendamos crear un nuevo grupo.", "chat-invite": "Invitación al chat", "chat-settings": "Configuración de la habitación", "click-here-for-announcements": "Haga clic aquí para anuncios", "click-to-join-group": "Haz clic para unirte a este grupo", - "confirm-add-admin-to-group": "¿Está seguro de que desea agregar a {{username}} como admin? Esto les permitirá enviar mensajes en este grupo.", + "confirm-add-admin-to-group": "¿Estás seguro de que desea agregar a {{username}} como admin? Esto le permitirá enviar mensajes en este grupo.", "confirm-add-to-group": "¿Estás seguro de que quieres invitar a {{username}} a {{roomName}}?", "confirm-remove-admin-from-group": "¿Está seguro de que desea eliminar a {{username}} como admin? Aunque no pueda enviar mensajes en este grupo, sus mensajes previos permanecerán visibles.", "copied-group-invite-code": "Código de invitación de grupo copiado correctamente", - "create-a-display-name": "Crear un nombre para mostrar", - "create-a-group": "Crear un grupo", + "create-a-display-name": "Crea un alias de Fedi", + "create-a-group": "Crea un grupo", "create-group": "Crea un grupo", - "create-or-join-a-new-group": "Crear o unirse a un nuevo grupo", - "disappearing-messages": "Mensajes que desaparecen", - "display-name": "Nombre para mostrar", + "create-or-join-a-new-group": "Crea o únete a un grupo nuevo", + "disappearing-messages": "Mensajes termporales", + "display-name": "Alias en Fedi", "display-name-guidance": "Puedes cambiar esto más tarde", - "edit-group": "Editar grupo", - "enter-a-username": "Introduzca un nombre de usuario", - "enter-display-name": "Introduzca un nombre para mostrar", - "fedi-community": "Comunidad Fedi", + "edit-group": "Edita el grupo", + "enter-a-username": "Introduce un alias de Fedi", + "enter-display-name": "Introduce un alias de Fedi", + "fedi-community": "Comunidad de Fedi", "fedi-community-message-preview": "¡Te damos la bienvenida a Fedi! Este canal te mantendrá informado sobre los eventos que ocurren en tu aplicación Fedi.", "former-member": "Ex miembro", "go-to-direct-chat": "Ir al chat directo", "group-invite": "Invitación al grupo", "group-name": "Nombre del grupo", - "group-not-found": "No se encontró ningún grupo", - "invalid-group": "Este no es un grupo de chat válido.", - "invalid-member": "Este no es un usuario de chat válido", - "invite-to-group": "Invitar al grupo", - "join-a-group": "Únete a un grupo", + "group-not-found": "Grupo no encontrado", + "invalid-group": "Este no es un chat grupal válido.", + "invalid-member": "Este no es un miembro del chat válido", + "invite-to-group": "Invita otros miembros al grupo", + "join-a-group": "Únete al grupo", "join-group": "Unirse al grupo", - "leave-group": "Abandonar grupo", + "leave-group": "Abandona el grupo", "leave-group-confirmation": "¿Estás seguro de que quieres abandonar el grupo? Se perderá todo el historial de mensajes en este grupo.", - "member-not-found": "No hay ningún usuario con el nombre de usuario '{{username}}'", - "need-registration-description": "Configure su nombre para mostrar para poder enviar y recibir con otros usuarios.", - "need-registration-title": "Prepárate para charlar", + "member-not-found": "No pudimos encontrar a ningun miembro con este nombre de usuario", + "need-registration-description": "Configura tu alias en Fedi para poder enviar y recibir con otros usuarios.", + "need-registration-title": "Prepárate para chatear", "new-chat": "Nuevo chat", "new-group": "Nuevo grupo", "new-message": "Nuevo mensaje", - "new-messages": "Nuevos mensajes", - "no-admins": "Aún no hay administradores de transmisión", - "no-messages": "Aún no hay conversación en esta sala.", - "no-one-is-in-this-group": "Todavía no hay nadie en este grupo.", + "new-messages": "Mensajes nuevos", + "no-admins": "Aún no hay administradores de difusión", + "no-messages": "Aún no hay conversaciones en esta sala", + "no-one-is-in-this-group": "Aún no hay miembros en esta sala", "no-users-found": "No se encontraron usuarios", "notification-always": "Siempre Notificar", "notification-mentions": "Sólo menciones", "notification-mute": "Silenciar", "notification-settings": "Configuración de notificaciones de chat", "notification-update-success": "Configuración de notificaciones de chat actualizada", - "open-camera-scanner": "Abrir escáner de cámara", + "open-camera-scanner": "Abre la cámara para escanear", "open-chat": "Conversación abierta", - "other-sent-payment": "{{name}} enviado {{recipient}} {{fiat}} ({{amount}}) {{memo}}", + "other-sent-payment": "{{name}} envió {{recipient}} {{fiat}} ({{amount}}) {{memo}}", "paid-by-name": "Pagado por {{name}}", - "paste-group-invite": "Pegar invitación de grupo", - "register-a-username": "Registrar un nombre de usuario", + "paste-group-invite": "Pega el código de invitación de grupo", + "register-a-username": "Registra un alias de Fedi", "remove-user": "Eliminar usuario del grupo", "removed-admin-from-group": "{{username}} ya no es un admin", "removed-member": "Miembro eliminado", - "room-settings": "Configuración de la habitación", - "scan-chat-invite": "Escanear invitación de chat", - "scan-group-invite": "Escanear invitación de grupo", - "scan-member-code-notice": "Muestra QR para compartir tu nombre de usuario", + "room-settings": "Configuración de la sala", + "scan-chat-invite": "Escanea una invitación de chat", + "scan-group-invite": "Escanea una invitación a un grupo", + "scan-member-code-notice": "Muestra el QR para compartir tu nombre de usuario", "select-or-start": "Selecciona un chat o comienza uno nuevo", - "send-a-message-to": "Enviar un mensaje a {{name}}", - "show-history-to-new-members": "Mostrar historial a nuevos usuarios", - "start-the-conversation": "Inicie la conversación.", + "send-a-message-to": "Envia un mensaje a {{name}}", + "show-history-to-new-members": "Ver historial a nuevos usuarios", + "start-the-conversation": "Inicia la conversación", "they-requested-payment": "{{name}} ha solicitado {{fiat}} ({{amount}}) {{memo}}", "they-sent-payment": "{{name}} te envió {{fiat}} ({{amount}}) {{memo}}", "this-is-a-chat-group": "Esta es una invitación a un grupo de chat.", - "try-inviting-someone": "Intenta invitar a alguien.", + "try-inviting-someone": "Intenta invitar otros miembros", "type-to-search-members": "Escribe para buscar usuarios en este grupo", "unknown-member": "Usuario desconocido", - "upgrade-chat": "Actualizar chat", + "upgrade-chat": "Actualiza el chat", "upgrade-chat-guidance": "Hemos hecho que el chat de Fedi sea más seguro y utilizable para personas de múltiples comunidades.", "upgrade-chat-item-subtitle-1": "Habla con cualquier persona en Fedi", "upgrade-chat-item-subtitle-2": "Npubs para todos", "upgrade-chat-item-subtitle-3": "De extremo a extremo", - "upgrade-chat-item-subtitle-4": "Enviar & recibir bitcoins", + "upgrade-chat-item-subtitle-4": "Envia & recibe bitcoins", "upgrade-chat-item-subtitle-5": "Pronto™", - "upgrade-chat-item-title-1": "Chats multicomunitarios", - "upgrade-chat-item-title-2": "Nombre para mostrar único", + "upgrade-chat-item-title-1": "Chats multi-comunidad", + "upgrade-chat-item-title-2": "Alis de Fedi único}", "upgrade-chat-item-title-3": "DM cifrados", "upgrade-chat-item-title-4": "Pagos sin esfuerzo", "upgrade-chat-item-title-5": "¡Más por venir!", @@ -419,8 +422,8 @@ "default-groups-info": "Crea un grupo público de solo anuncios que se configurará como grupo predeterminado al que los usuarios se unen automáticamente.", "developer-mode-activated": "¡Ahora eres desarrollador!", "developer-mode-deactivated": "Ya no eres desarrollador...", - "download-logs": "Descargar registros", - "export-transactions-csv": "Exportar transacciones CSV", + "download-logs": "Descarga los registros", + "export-transactions-csv": "Exporta tus transacciones en formato CSV", "log-fcm-token": "Registrar token de FCM", "logs": "Registros", "nightly": "Nightly", @@ -428,64 +431,65 @@ "share-state": "Compartir estado de la aplicación" }, "federations": { - "add-federation": "Añadir una Comunidad", + "add-federation": "Agrega una Comunidad", "camera-access-information": "Para unirte a una Comunidad, necesitas permitir a Fedi acceder a tu cámara para escanear el código de invitación de la Comunidad", - "copied-federation-invite": "Enlace de invitación de federación copiado", - "enter-federation-code": "Introduce un código de federación", + "copied-federation-invite": "Enlace de invitación a comunidad copiado", + "enter-federation-code": "Introduce un código de comunidad", "federation-details": "Detalles de la Comunidad", "federation-invite": "Invitación a la Comunidad", "federation-terms": "Términos de la Comunidad", - "invite-members": "Invitar a usuarios", - "join-federation": "Unirse a una nueva Comunidad", - "leave-federation": "Abandonar la Comunidad", - "leave-federation-confirmation": "¿Estás seguro de que quieres abandonar esta federación?", + "invite-members": "Invita miembros a la comunidad", + "join-federation": "Únete a una nueva Comunidad", + "leave-federation": "Abandona la Comunidad", + "leave-federation-confirmation": "¿Estás seguro de que quieres abandonar esta comunidad?", "leave-federation-withdraw-first": "Debes retirar tus fondos antes de salir", - "leave-federation-withdraw-pending-stable-first": "Su retiro de {{currency}} aún se está procesando. Inténtalo de nuevo en 10 minutos.", - "leave-federation-withdraw-stable-first": "Debes retirar todo tu saldo de {{currency}} antes de salir.", - "paste-federation-code": "Pegar código de federación", + "leave-federation-withdraw-pending-stable-first": "Tu retiro en {{currency}} aún se está procesando. Inténtalo de nuevo en 10 minutos.", + "leave-federation-withdraw-stable-first": "Debes retirar todo tu saldo en {{currency}} antes de salir.", + "paste-federation-code": "Pegar código de comunidad", "paste-federation-code-instead": "Pegar código desde el portapapeles", - "scan-federation-invite": "Escanear la invitación a una Comunidad" + "scan-federation-invite": "Escanea la invitación de la comunidad" }, "fedimods": { - "add-a-mod": "Agregar un mod", - "add-fedi-mod": "Agregar modo Fedi", - "add-mods-homescreen": "Añade Mods a tu pantalla de inicio de Fedi", - "debug-mode": "Modo de depuración de Fedi Mod", + "add-a-mod": "Agrega un Mod", + "add-fedi-mod": "Agrega un Fedi Mod", + "add-mods-homescreen": "Agrega Mods a tu pantalla de inicio de Fedi", + "debug-mode": "Modo debug de Fedi Mods", "debug-mode-info": "Incorpora una herramienta de desarrollo (Eruda) en el navegador al abrir Fedi Mods", "enter-amount-to-withdraw": "Introduce el monto que deseas retirar de {{fediMod}}", - "fedi-mods": "Modificaciones Fedi", - "leave-page": "¿Dejar página?", + "fedi-mods": "Fedi Mods", + "leave-page": "¿Abandonar página?", "leave-page-confirmation": "¿Estás seguro de que quieres abandonar esta página? Es posible que los cambios no se guarden", - "login-failed": "Error de inicio de sesión", - "login-to": "Iniciar sesión en", - "mod-title": "Título del mod", + "login-failed": "El inicio de sesión falló", + "login-to": "Inicia sesión en", + "mod-title": "Título del Mod", "payment-request": "Solicitud de pago de {{fediMod}}", - "stable-balance-enabled": "Saldo estable", - "stable-balance-enabled-info": "Permite la capacidad de convertir sats en moneda estable", + "stable-balance-enabled": "Stable Balance", + "stable-balance-enabled-info": "Habilita la capacidad de convertir sats a moneda estable", + "wants-to-pay-you": "{{fedimod}} quiere pagarte", "wants-to-send-you": "{{fediMod}} quiere enviarte", "wants-you-to-pay": "{{fediMod}} quiere que pagues", - "your-mods": "Tus modificaciones" + "your-mods": "Tus Mods" }, "fees": { "guidance-ecash": "¡Elegante! Enviar dinero a amigos de su federación es más barato, más fácil y, además, más divertido. 😎", - "guidance-lightning": "🤑 ¿Quieres ahorrar dinero en tarifas? ¡Envía el chat en su lugar!", - "guidance-onchain": "El envío de bitcoins en cadena está sujeto a tarifas de red. Honestamente, es mejor para cantidades carnosas.", + "guidance-lightning": "🤑 ¿Quieres ahorrar dinero en tarifas? ¡Envía por el chat en su lugar!", + "guidance-onchain": "El envío de bitcoins vía On-chain está sujeto a tarifas de red. Es aconsejable para cantidades sustanciales", "guidance-stable-balance": "*Este es el máximo que podrías pagar para mantener este saldo durante un año. ¡Puede que sea menos! 😉" }, "nostr": { "kind-application-data": "Datos específicos de la aplicación", - "kind-authentication": "Iniciar sesión con Nostr", - "kind-connect": "Conectar Nostr", - "kind-default": "Mensaje de nosotros", + "kind-authentication": "Inicia sesión con Nostr", + "kind-connect": "Nostr connect", + "kind-default": "Mensajes de Nostr", "kind-encrypted-dm": "Mensaje cifrado", - "kind-highlight": "Destacar", + "kind-highlight": "Highlight", "kind-metadata": "Metadatos", - "kind-note": "Nota de texto breve", + "kind-note": "Nota de texto corta", "kind-reaction": "Reacción", - "kind-repost": "Volver a publicar", - "kind-zap": "Borrar", - "kind-zap-request": "Solicitud de descarga", - "log-in-to-mod": "Inicie sesión en {{fediMod}} con {{method}}", + "kind-repost": "Repost", + "kind-zap": "Zap", + "kind-zap-request": "Solicitud de Zap", + "log-in-to-mod": "Inicia sesión en {{fediMod}} con {{method}}", "wants-you-to-sign": "{{fediMod}} quiere que firmes" }, "notifications": { @@ -494,18 +498,18 @@ "open-chat": "Abrir conversación" }, "omni": { - "action-enter-ln-address": "Ingrese la dirección Lightning", - "action-enter-text": "Ingrese texto", + "action-enter-ln-address": "Introduce la dirección Lightning", + "action-enter-text": "Ingresa texto", "action-enter-url": "Introducir URL", "action-enter-username": "Introduce nombre de usuario", - "action-enter-username-or-ln": "Introduce nombre de usuario / dirección Lightning", - "action-paste": "Pegar desde el portapapeles", - "action-scan": "Escanear código QR", - "action-upload": "Subir imagen QR", - "camera-permission-denied": "Busca los ajustes del dispositivo para cambiar los permisos de la cámara.", - "camera-permission-request": "Permitir el acceso a la cámara para escanear.", + "action-enter-username-or-ln": "Introduce nombre de usuario o dirección Lightning", + "action-paste": "Pegar", + "action-scan": "Escanea un código QR", + "action-upload": "Sube una imagen QR", + "camera-permission-denied": "Ve a los ajustes del dispositivo para cambiar los permisos de la cámara.", + "camera-permission-request": "Permite acceso a la cámara para escanear.", "confirm-ecash-token": "Esto es un token eCash. ¿Deseas canjearlo?", - "confirm-federation-invite": "Esto es una invitación para unirte a una federación. ¿Quieres unirte?", + "confirm-federation-invite": "Esto es una invitación para unirte a una comunidad. ¿Quieres unirte?", "confirm-fedi-chat": "Esto es un enlace de chat. ¿Te gustaría acceder?", "confirm-fedi-chat-group-invite": "Esta es una invitación a un grupo de chat, ¿quieres unirte?", "confirm-lightning-pay": "Esto es una solicitud de pago Lightning. ¿Quieres realizar el pago?", @@ -515,14 +519,14 @@ "confirm-website-url": "Esto es la URL de un sitio web, ¿quieres abrirla en tu navegador?", "search-no-history-header": "No tienes un historial con este usuario", "search-no-results": "No se encontró al usuario \"\"{{query}}\"\"", - "search-placeholder-ln-address": "Ingrese una dirección relámpago", - "search-placeholder-username": "Introduce un nombre de usuario", - "search-placeholder-username-or-ln": "Introduce un nombre de usuario o dirección Lightning", + "search-placeholder-ln-address": "Ingresa una dirección de Lightning", + "search-placeholder-username": "Ingresa un nombre de usuario", + "search-placeholder-username-or-ln": "Ingresa un nombre de usuario o una dirección de Lightning", "unsupported-bolt12": "Las ofertas de BOLT 12 todavía no son compatibles. ¡Lo siento!", "unsupported-chat-invite": "Este es un código de sala de chat. Tu nombre de usuario debe ser invitado a una sala de chat antes de unirte.", - "unsupported-legacy-chat": "Este QR de chat es de una versión de la aplicación que ya no es compatible. ¡Lo siento!", + "unsupported-legacy-chat": "Este código QR de chat es de una versión de la aplicación que ya no es compatible. ¡Lo siento!", "unsupported-no-federation": "No puedes usar eso antes de unirte a una federación. En su lugar, intente escanear una invitación de una federación.", - "unsupported-on-chain": "Las direcciones de Bitcoin onchain todavía no son compatibles. ¡Lo siento!", + "unsupported-on-chain": "Las direcciones de Bitcoin On-Chain todavía no son compatibles. ¡Lo siento!", "unsupported-unknown": "Hmm, este no es un formato reconocido. ¡Lo siento!" }, "onboarding": { @@ -531,73 +535,73 @@ "by-clicking-you-agree-user-agreement": "Al unirse a esta Comunidad, usted acepta los términos del Acuerdo de usuario", "chat-earn-save-spend": "Chatea, gana, ahorra y gasta dinero de forma privada con tu comunidad", "community-first": "Primero la comunidad", - "continue-to-fedi": "Continuar a Fedi", - "create-username": "Crear nombre de usuario", + "continue-to-fedi": "Continua a Fedi", + "create-username": "Crea un nombre de usuario", "create-your-username": "Crea tu nombre de usuario", "earn-and-save": "Gana y ahorra", - "enter-username": "Introduzca su nombre de usuario", + "enter-username": "Introduce un nombre de usuario", "greeting-image": "Excelente", "greeting-instructions": "Ahora podrás enviar dinero, comunicarte con tu comunidad y mucho más con Fedi.", - "guidance-1": "Un espacio para comunicarte y gestionar tu dinero de manera sencilla y privada con las personas en las que más confías.", + "guidance-1": "Un espacio para comunicarte y gestionar tu dinero de manera sencilla y privada con las personas en las que más confías y tu comunidad", "guidance-2": "Fedi utiliza tu comunidad para simplificar la copia de seguridad, proteger tu dinero, cambiarlo a moneda local y brindarte asistencia.", "guidance-3": "Con Fedi, disfrutas de saldos, pagos y comunicaciones privadas de manera predeterminada y sin complicaciones.", "guidance-4": "Fedi utiliza Bitcoin y la red Lightning para conectarte a oportunidades globales que te permiten ganar dinero en línea según tus propios términos.", - "guidance-public-federations": "Pruebe una comunidad de la lista Awesome Fedimint", + "guidance-public-federations": "Prueba una comunidad de la lista Awesome Fedimint", "i-accept": "Acepto", "i-do-not-accept": "No acepto", "im-returning": "estoy regresando", - "join-new-member": "Unirse como nuevo usuario", + "join-new-member": "Unirte como nuevo usuario", "join-returning-member": "Soy un usuario que regresa", "new-users-disabled-notice": "Lo sentimos, esta federación no acepta nuevos usuarios.", "nice-to-meet-you": "Encantado de conocerte, {{username}}", "simple-and-private": "Sencillo y privado", "terms-and-conditions": "Términos y condiciones", - "unsupported-notice": "Esta federación ya no es compatible. No se puede unir.", - "username-guidance": "Ingrese un nombre de usuario que contenga letras minúsculas sin espacios", - "username-instructions": "Su nombre de usuario será la forma en que otros usuarios lo identificarán.", + "unsupported-notice": "Esta comunidad ya no es compatible. No te puedes unir.", + "username-guidance": "Ingresa un nombre de usuario que contenga letras en minúscula y sin espacios", + "username-instructions": "Tu nombre de usuario será la forma en que otros usuarios te identificarán.", "welcome-back-to-federation": "Bienvenido de nuevo a {{federation}}", "welcome-instructions-new": "Como nuevo usuario de la federación, recibirás una billetera nueva.", "welcome-instructions-returning": "Como usuario que regresa a la federación, su billetera será restaurada. Esto puede tardar unos minutos.", - "welcome-instructions-unknown": "Los nuevos usuarios reciben una cartera nueva. A los usuarios que regresen se les pedirá que recuperen su cartera.", + "welcome-instructions-unknown": "Los usuarios nuevos reciben una billetera nueva. A los usuarios que regresen se les pedirá que recuperen su cartera.", "welcome-to-federation": "Te damos la bienvenida a {{federation}}", - "welcome-to-fedi": "Te damos la bienvenida a Fedi", + "welcome-to-fedi": "Te damos la bienvenida a Fedi Bravo", "yes-create-account": "Sí, crear una cuenta" }, "parser": { "unrecognized": "Formato de datos no reconocido", "unsupported-bolt11-zero-amount": "Facturas Lightning sin monto no son compatibles. Genera una nueva factura con un monto e intenta escanearla nuevamente.", - "unsupported-lnurl": "Tipo de LNURL '{{type}}' no compatible " + "unsupported-lnurl": "Tipo de LNURL '{{type}}' no compatible" }, "permissions": { "allow-camera-description": "Escanea códigos QR, chatea con nombres de usuario, envía dinero y más", "allow-camera-title": "Permitir el acceso a la cámara", "allow-notifications-description": "Nuevos mensajes de chat, pagos y anuncios", - "allow-notifications-title": "Permitir ver las notificaciones", + "allow-notifications-title": "Permite las notificaciones", "allow-storage-description": "Sube archivos, configura tu foto de perfil y más", "allow-storage-title": "Permitir el acceso al almacenamiento", "update-later-disclaimer": "Esto se puede actualizar más tarde." }, "pin": { - "back-up-your-account": "Haga una copia de seguridad de su cuenta", + "back-up-your-account": "Haz una copia de respaldo de tu cuenta", "backup-notice": "Si olvida su PIN, su copia de seguridad es la única forma de recuperar su cuenta.", - "change-pin": "Cambiar PIN", - "create-a-pin": "Crear un PIN", - "create-new-pin": "Crear nuevo PIN", - "enter-current-pin": "Ingrese el PIN actual", - "enter-pin": "Ingrese su PIN", + "change-pin": "Cambia tu PIN", + "create-a-pin": "Crea un PIN", + "create-new-pin": "Crea un PIN nuevo", + "enter-current-pin": "Ingresa tu PIN actual", + "enter-pin": "Ingresa tu PIN", "forgot-your-pin": "¿Olvidaste tu PIN?", - "pin-access": "Acceso PIN", + "pin-access": "Acceso con PIN", "pin-doesnt-match": "El PIN no coincide", "pin-setup-successful": "¡Configuración del PIN exitosa!", - "re-enter-pin": "Vuelva a introducir el PIN", + "re-enter-pin": "Vuelve a introducir el PIN", "recover-with-backup": "Recupera con tu copia de seguridad", "recovery-notice": "Si olvidaste tu PIN, puedes acceder a Fedi con tu copia de seguridad y crear un nuevo PIN.", - "unlocking-fedi-app": "Desbloqueo de la aplicación Fedi" + "unlocking-fedi-app": "Desbloqueando la aplicación de Fedi" }, "popup": { "ended": "Terminado", - "ended-description": "Esta federación temporal ha finalizada {{date}}.", - "ending-description": "Esta federación temporal finalizará el {{date}}. Los fondos restantes se gestionarán a discreción de los guardianes.", + "ended-description": "Esta comunidad temporal ha finalizado en {{date}}.", + "ending-description": "Esta comunidad temporal finalizará el {{date}}. Los fondos restantes se gestionarán a discreción de los guardianes.", "ending-in": "Termina en {{time}}" }, "receive": { @@ -608,54 +612,54 @@ "bitcoin-request": "Solicitud de Bitcoin", "camera-access-information": "Para recibir dinero sin conexión, necesitarás permitir que Fedi acceda a tu cámara para escanear un código de pago sin conexión", "copied-payment-code": "Solicitud de pago copiada", - "create-lightning-request": "Crear solicitud de Lightning", - "enable-onchain-deposits": "Habilitar depósitos onchain", + "create-lightning-request": "Crea una solicitud de Lightning", + "enable-onchain-deposits": "Habilita depósitos On-chain", "hide-other-methods": "Ocultar otros métodos", "instructions": "Introduce el monto que deseas recibir", "join-new-federation": "Únete a una nueva federación", "join-to-receive": "Únete a {{federation}} para recibir", "maximum-invoice-amount": "El monto máximo es {{maxAmount}} SATS", - "onchain-notice": "Los depósitos a la blockchain suelen tardar ~10 horas en confirmarse. Utiliza Lightning para transacciones instantáneas.", + "onchain-notice": "Los depósitos On-chain suelen tardar ~10 horas en confirmarse. Utiliza Lightning para transacciones instantáneas.", "other-methods": "Otros metodos", "pending-transaction": "transacción pendiente", - "receive-amount-unit": "Recibir {{amount}} {{unit}}", - "receive-bitcoin": "Recibir bitcoin", - "receive-bitcoin-offline": "Recibir bitcoin sin conexión", + "receive-amount-unit": "Recibe {{amount}} {{unit}}", + "receive-bitcoin": "Recibe bitcoin", + "receive-bitcoin-offline": "Recibe bitcoin sin conexión", "reject-payment": "Rechazar pago", - "request-bitcoin": "Solicitar bitcoin", - "request-sats": "Solicitar {{amount}} SAT", - "request-via-lightning": "Solicitud vía Lightning", + "request-bitcoin": "Solicita bitcoin", + "request-sats": "Solicita {{amount}} sats", + "request-via-lightning": "Solicita vía Lightning", "send-a-lightning-request": "Enviar una solicitud lightning a {{username}}", - "withdraw-from-domain": "Retirar de {{domain}}", + "withdraw-from-domain": "Retira desde {{domain}}", "you-received": "Has recibido", "you-received-amount-unit": "Recibiste {{amount}} {{unit}}" }, "recovery": { "camera-access-information": "Para facilitar la recuperación, deberás permitir que Fedi acceda a tu cámara para escanear el código de recuperación social.", - "cancel-social-recovery": "Cancelar la recuperación social", - "cancel-social-recovery-detail": "¿Desea cancelar la recuperación social?", + "cancel-social-recovery": "Cancela la recuperación social", + "cancel-social-recovery-detail": "¿Deseas cancelar la recuperación social?", "choose-method": "Elige un método de recuperación", - "choose-method-instructions": "Elige el método que utilizaste para hacer una copia de seguridad de tu cartera cuando te uniste por primera vez a {{federation}}", - "choose-wallet-option": "Elija una opción de billetera", - "complete-social-recovery": "Completar recuperación social", - "create-a-new-wallet": "Crear una nueva billetera", - "create-a-new-wallet-instead": "Crea una nueva billetera en su lugar", - "create-new-wallet": "Crear nueva billetera", + "choose-method-instructions": "Elige el método que utilizaste para hacer la copia de respaldo de tu cartera cuando te uniste por primera vez a {{federation}}", + "choose-wallet-option": "Elige una opción de billetera", + "complete-social-recovery": "Completa la recuperación social", + "create-a-new-wallet": "Crea una billetera nueva", + "create-a-new-wallet-instead": "Crea una billetera nueva en su lugar", + "create-new-wallet": "Crea una nueva billetera", "create-new-wallet-guidance": "Solo se podrá acceder a esta billetera desde este dispositivo, a menos que recupere & transferir a otro dispositivo", "download-failed": "Error al descargar", - "fresh-wallet": "Cartera nueva en este dispositivo", - "from-different-device": "De un dispositivo diferente a este", + "fresh-wallet": "Billetera nueva en este dispositivo", + "from-different-device": "Desde un dispositivo diferente a este", "guardian-approval-instructions": "Los guardianes deben verificar que eres la persona del video de introducción en Fedi para poder recuperar tu dinero.", - "guardian-approval-step-1": "1. Organiza reuniones con tus guardianes (ve los guardianes abajo)", + "guardian-approval-step-1": "1. Organiza una reunión con tus guardianes (ve los guardianes abajo)", "guardian-approval-step-2": "2. Solicita al guardián que escanee tu código QR", "guardian-approval-step-3": "3. Ellos verán tu video y verificarán tu identidad", "guardian-approval-step-4": "4. Si los guardianes confirman guardastetu identidad, tu saldo será restaurado en tu cartera", "guardian-approvals": "Aprobaciones de los guardianes", "guardian-qr-instructions": "Los guardianes deberán escanear este código QR para iniciar el proceso de recuperación social.", - "guardians-remaining": "{{guardians}} restante", - "invalid-qr-code": "Código QR de recuperación social inválido", - "locate-social-recovery-file": "Ubica tu archivo de recuperación social", - "locate-social-recovery-instructions-1": "Por favor, ubica este archivo en tus contactos o almacenamiento personal y ábrelo para iniciar la recuperación. Algunos lugares donde puedes buscar son...", + "guardians-remaining": "{{guardians}} restantes", + "invalid-qr-code": "Código QR de respaldo social inválido", + "locate-social-recovery-file": "Abre tu archivo de respaldo social", + "locate-social-recovery-instructions-1": "Pudiste haber compartido este archivo con tus contactos o haberlo guardado en tu almacenamiento personal. Algunos lugares donde puedes buscar son...", "locate-social-recovery-instructions-3": "El nombre del archivo de Fedi es similar a:", "locate-social-recovery-instructions-check-1": "Descargas", "locate-social-recovery-instructions-check-2": "Almacenamiento en la nube", @@ -665,53 +669,53 @@ "locked-device-guidance-2": "Este dispositivo {{deviceName}} ahora está bloqueado. ❌", "locked-device-guidance-3": "Si desea configurar otra billetera en este dispositivo, elimine y reinstale Fedi. 👍", "new-wallet": "Nueva billetera", - "nothing-to-download": "Nada que descargar de guardián", - "open-qr-code": "Abrir código QR", - "opening-backup-file-failed": "Error al abrir el archivo de copia de seguridad", + "nothing-to-download": "Nada que descargar del guardián", + "open-qr-code": "Abre el código QR", + "opening-backup-file-failed": "Error al abrir el archivo de copia de respaldo", "opening-backup-file-failed-instructions": "El archivo de copia de seguridad {{fileName}} no se pudo abrir. Por favor, intenta recuperarlo desde otra de tus ubicaciones compartidas.", - "paste-social-recovery-code": "Pegar código de recuperación social", + "paste-social-recovery-code": "Pega el código de respaldo social", "paste-social-recovery-code-instead": "En lugar de eso, pega el código de recuperación social", - "personal-recovery": "Recuperación personal", - "personal-recovery-instructions": "Ingresa las 12 palabras que guardaste al realizar la primera copia de seguridad de tu cartera", + "personal-recovery": "Respaldo personal", + "personal-recovery-instructions": "Ingresa las 12 palabras que guardaste al realizar la primera copia de respaldo de tu billetera", "personal-recovery-method": "Si guardaste 12 palabras de recuperación, puedes reintroducirlas para recuperar tu cartera.", - "recover-a-wallet": "Recuperar una cartera", - "recover-wallet": "Recuperar cartera", + "recover-a-wallet": "Recupera una billeta", + "recover-wallet": "Recuperar billetera", "recover-wallet-with-balance": "En este momento, la recuperación no es compatible con carteras que tengan saldo. Por favor, retira tus fondos primero.", "recovering-your-wallet": "Estamos en proceso de recuperar tu cartera. Por favor, vuelve a verificar más tarde", "recovery-assist": "Asistencia para recuperación", "recovery-assist-confirm-check-1": "Escribirás 12 palabras, que serán necesarias para restaurar la cartera de tu Comunidad.", "recovery-assist-confirm-check-2": "El entorno inmediato de la persona es seguro", - "recovery-assist-description": "Un usuario de tu federación está pidiendo tu ayuda para recuperar su cartera.", + "recovery-assist-description": "Un usuario de tu comunidad está pidiendo tu ayuda para recuperar su cartera.", "recovery-assist-instructions-1": "1. Verifica que el usuario se encuentre tranquilo y en un lugar seguro.", "recovery-assist-instructions-2": "2. Escanea el código QR del usuario.", - "recovery-assist-instructions-3": "3. Mira su video de copia de seguridad de Fedi.", - "recovery-assist-instructions-4": "4. Confirma que es la misma persona en el video de copia de seguridad.", - "recovery-assist-instructions-5": "5. Aprobar o denegar la solicitud de recuperación del usuario", + "recovery-assist-instructions-3": "3. Mira su video de copia de respaldo de Fedi.", + "recovery-assist-instructions-4": "4. Confirma que sean la misma persona que en el video de copia de respaldo", + "recovery-assist-instructions-5": "5. Aprueba o rechaza la solicitud de recuperación del usuario", "recovery-assist-process": "Proceso de asistencia de recuperación", - "recovery-assist-thank-you": "Gracias por ayudar a recuperar la cartera de un usuario de la Comunidad", + "recovery-assist-thank-you": "Gracias por ayudar a recuperar la billetera de un usuario de la Comunidad", "recovery-confirm-identity-instructions-1": "Mira el video para confirmar la identidad del usuario.", - "recovery-confirm-identity-instructions-2": "¿¿Este video muestra al mismo usuario que está solicitando la recuperación social en este momento?", + "recovery-confirm-identity-instructions-2": "¿Este video muestra al mismo usuario que está solicitando la recuperación social en este momento?", "recovery-confirm-identity-no": "No, la persona en el video no es la misma persona", "recovery-confirm-identity-yes": "Sí, la persona en el video es la misma persona", "recovery-in-progress-balance": "La recuperación está en progreso. Tus saldos estarán disponibles pronto.", "recovery-in-progress-chat-payments": "La recuperación está en progreso. Los pagos por chat estarán disponibles pronto.", "recovery-in-progress-payments": "La recuperación está en progreso. Los pagos estarán disponibles pronto.", "search-files": "Buscar archivos", - "select-a-device": "Seleccione un dispositivo", - "select-a-device-guidance": "Seleccione un dispositivo desde el que transferir una billetera existente", - "social-recovery": "Recuperación social", - "social-recovery-instructions": "Abre el archivo de recuperación social de Fedi que grabaste al crear tu video de copia de seguridad. Recuerda que este archivo es el que compartiste con tus amigos.", + "select-a-device": "Selecciona un dispositivo", + "select-a-device-guidance": "Selecciona un dispositivo desde el que transferir una billetera existente", + "social-recovery": "Respaldo social", + "social-recovery-instructions": "Abre el archivo de respaldo social de Fedi que grabaste al crear tu video de copia de respaldo. Recuerda que este archivo es te recomendamos guardar y compartir con tus amigos.", "social-recovery-method": "Si grabaste un video, reúnete con los guardianes para que confirmen tu identidad", - "social-recovery-steps": "Pasos para la recuperación social", + "social-recovery-steps": "Pasos del respaldo social", "social-recovery-unsuccessful": "Recuperación social sin éxito", "social-recovery-unsuccessful-instructions": "Intenta nuevamente con los guardianes y proporciona evidencia de tu identidad", - "start-personal-recovery": "Iniciar recuperación personal", - "start-social-recovery": "Iniciar recuperación social", + "start-personal-recovery": "Inicia la recuperación personal", + "start-social-recovery": "Inicia la recuperación social", "successfully-opened-fedi-file": "Has abierto exitosamente tu archivo de Fedi", - "transfer-existing-wallet": "Transferir billetera existente", - "transfer-existing-wallet-guidance-1": "Esto traerá su billetera existente a este dispositivo y bloqueará la billetera en el otro dispositivo.", + "transfer-existing-wallet": "Transfiere una billetera existente", + "transfer-existing-wallet-guidance-1": "Esto traerá tu billetera existente a este dispositivo y bloqueará la billetera en el otro dispositivo.", "transfer-existing-wallet-guidance-2": "No necesitas el dispositivo antiguo para realizar la transferencia.", - "try-social-recovery-again": "Intentar la recuperación social de nuevo", + "try-social-recovery-again": "Intenta la recuperación social de nuevo", "wallet-transfer": "Transferencia de billetera", "wallet-was-transferred": "La billetera fue transferida", "you-completed-personal-recovery": "Has completado la recuperación personal", @@ -719,25 +723,25 @@ }, "send": { "camera-access-information": "Para enviar dinero, necesitarás permitir que Fedi acceda a tu cámara para escanear una solicitud de pago", - "confirm-ecash-send": "Confirmar envío de efectivo electrónico", - "confirm-send": "Confirmar envío", + "confirm-ecash-send": "Confirma el envio de eCash", + "confirm-send": "Confirma el envio", "copied-offline-payment": "Pago sin conexión copiado", "enter-payment-request": "Introduce una solicitud de pago", - "hold-to-confirm-send": "Mantenga pulsado Enviar para confirmar", + "hold-to-confirm-send": "Mantén pulsado Enviar para confirmar", "i-have-sent-payment": "He enviado el pago", "offline-send-warning": "En el siguiente paso, cuando se presente el QR, los SATS serán deducidos de su cartera", "paste-payment-request": "Pegar solicitud de pago", "paste-payment-request-instead": "Pegar solicitud desde el portapapeles", "refund-in-block": "Reembolso en bloque {{block}}", - "scan-qr-code": "Escanear un código QR de Lightning", - "send-amount-unit": "Enviar {{amount}} {{unit}}", - "send-bitcoin": "Enviar bitcoin", - "send-bitcoin-offline": "Enviar bitcoin sin conexión", + "scan-qr-code": "Escanea un código QR de Lightning", + "send-amount-unit": "Envia {{amount}} {{unit}}", + "send-bitcoin": "Envia bitcoin", + "send-bitcoin-offline": "Envia bitcoin sin conexión", "send-from": "Enviado desde", - "send-offline": "Enviar sin conexión", - "send-sats": "Enviar {{amount}} SATS", - "send-to": "Enviar a", - "send-to-offline-user": "Enviar a un usuario sin conexión", + "send-offline": "Envia sin conexión", + "send-sats": "Envia {{amount}} SATS", + "send-to": "Envia a", + "send-to-offline-user": "Envia a un usuario sin conexión", "you-are-sending": "Estás enviando", "you-are-sending-amount-unit": "Estás enviando {{amount}} {{unit}}", "you-sent": "Has enviado", @@ -745,45 +749,45 @@ }, "settings": { "currency-names": { - "ars": "peso argentino", + "ars": "Peso argentino", "aud": "Dólar australiano", - "bdt": "taka bangladesí", + "bdt": "Taka bangladesí", "bif": "Franco burundés", "brl": "Real brasileño", "bwp": "Pula de Botsuana", "cad": "Dolar canadiense", - "cdf": "franco congoleño", - "cfa": "franco centroafricano", - "clp": "peso chileno", - "cop": "peso colombiano", + "cdf": "Franco congoleño", + "cfa": "Franco centroafricano", + "clp": "Peso chileno", + "cop": "Peso colombiano", "crc": "Colón costarricense", - "cup": "peso cubano", - "czk": "corona checa", + "cup": "Peso Cubano", + "czk": "Corona checa", "djf": "Franco de Yibuti", - "ern": "Nakfa eritrea", + "ern": "Nakfa", "etb": "Birr etíope", - "eur": "Euro de la UE", + "eur": "Euro", "gbp": "Libra esterlina británica", - "ghs": "cedi ghanés", + "ghs": "Cedi - could also work", "gtq": "Quetzal guatemalteco", "hkd": "Dolar de Hong Kong", "hnl": "Lempira hondureña", - "idr": "rupia indonesia", + "idr": "Rupia indonesia", "inr": "Rupia india", "kes": "Chelín keniano", "krw": "Coreano ganó", - "lbp": "libra libanesa", + "lbp": "Libra libanesa", "mmk": "Kyat birmano", "mwk": "Kwacha de Malawi", - "mxn": "peso mexicano", + "mxn": "Peso mexicano", "myr": "Ringgit malayo", - "nad": "dólar namibio", - "ngn": "naira nigeriana", + "nad": "Dólar namibio", + "ngn": "Naira nigeriana", "nio": "Córdoba nicaragüense", - "pen": "Nuevo sol peruano", - "php": "peso filipino", + "pen": "Nuevo Sol peruano", + "php": "Peso filipino", "pkr": "Rupia paquistaní", - "rwf": "franco ruandés", + "rwf": "Franco ruandés", "sdg": "Libra sudanesa", "sos": "Chelín somalí", "srd": "dólar surinamés", @@ -791,16 +795,16 @@ "thb": "Baht tailandés", "ugx": "Chelines ugandeses", "usd": "Dólar estadounidense", - "uyu": "peso uruguayo", - "ves": "bolívares", - "vnd": "dong vietnamita", - "xaf": "Camerún", - "zar": "rand sudafricano", + "uyu": "Peso uruguayo", + "ves": "Bolívares", + "vnd": "Dong vietnamita", + "xaf": "Franco CFA de África Central", + "zar": "Rad sudafricano", "zmw": "Kwacha zambiano" } }, "stabilitypool": { - "amount-may-vary": "La cantidad puede variar", + "amount-may-vary": "El monto puede variar", "amount-may-vary-during-withdraw": "El monto puede variar durante el retiro", "amount-pending": "{{amount}} pendiente", "available-to-deposit": "Disponible para depositar", @@ -808,36 +812,36 @@ "beta-enjoy-responsibly": "BETA - ¡Disfruta responsablemente!", "bitcoin-amount": "Cantidad en bitcoin", "bitcoin-balance": "Saldo en bitcoin", - "confirm-deposit": "Confirmar depósito", - "confirm-withdrawal": "Confirmar retiro", - "currency-balance": "{{currency}} saldo", + "confirm-deposit": "Confirma el depósito", + "confirm-withdrawal": "Confirma el retiro", + "currency-balance": "Saldo en {{currency}}", "current-value": "Valor actual", "deposit-amount": "Cantidad del depósito", "deposit-from": "Depósito desde", "deposit-intiated": "Depósito iniciado", "deposit-pending": "+{{amount}} depósito pendiente...", "deposit-time": "Tiempo de espera", - "deposit-to": "Depositar a", - "deposit-to-balance": "Depositar en el saldo de {{currency}}", + "deposit-to": "Deposita a", + "deposit-to-balance": "Deposita en el saldo de {{currency}}", "deposit-value": "Valor del depósito", "deposits-disabled-by-federation": "Los depósitos del Fondo de Estabilidad han sido inhabilitados por la Comunidad", "details-and-fee": "Detalles y comisiones", - "enter-deposit-amount": "Ingrese el monto del depósito", - "enter-withdrawal-amount": "Ingrese el monto del retiro", + "enter-deposit-amount": "Ingresa el monto del depósito", + "enter-withdrawal-amount": "Ingresa el monto del retiro", "fees-paid": "Comisión pagada", "max-stable-balance-amount": "Se alcanzó el monto máximo de saldo estable", "minutes": "{{minutes}} minutos", "more-than-an-hour": "1+ hora", - "no-bitcoin-notice": "Debes tener bitcoins para depositar en el saldo de {{currency}}", + "no-bitcoin-notice": "Debes tener saldo en bitcoin para depositar en tu saldo en {{currency}}", "one-minute": "1 minuto", "one-second": "1 segundo", - "pending-withdrawal-blocking": "Espere a que se procesen los retiros pendientes antes de realizar más depósitos o retiros.", + "pending-withdrawal-blocking": "Espera a que se procesen los retiros pendientes antes de realizar más depósitos o retiros.", "seconds": "{{seconds}} segundos", "stable-balance": "Balance Estable", "stable-balance-beta": "Esta es una función beta. Disfruta de manera responsable con pequeñas cantidades e informa cualquier error en Configuración.", - "will-be-deposited": "{{amount}} se depositará en {{expectedWait}}", - "will-be-withdrawn": "{{amount}} se retirará en {{expectedWait}}", - "withdraw-to": "Retirar a", + "will-be-deposited": "{{amount}} será depositada en {{expectedWait}}", + "will-be-withdrawn": "{{amount}} será enviada en {{expectedWait}}", + "withdraw-to": "Retira a", "withdrawal-amount": "Cantidad de retiro", "withdrawal-from": "Retiro de", "withdrawal-from-balance": "Retiro del saldo de {{currency}}", @@ -846,12 +850,12 @@ "withdrawal-time": "tiempo de retiro", "withdrawal-value": "valor de retiro", "you-deposited": "depositaste", - "you-withdrew": "te retiraste" + "you-withdrew": "Retiraste" }, "wallet": { - "network-notice": "Esta federación utiliza {{network}} SATS", - "show-fiat-txn-amounts": "Mostrar montos de transacciones fiduciarias", - "show-fiat-txn-amounts-info": "Los montos de las transacciones se mostrarán en la moneda fiduciaria seleccionada" + "network-notice": "Esta comunidad utiliza {{network}} SATS", + "show-fiat-txn-amounts": "Mostrar montos de transacciones en dinero fiat", + "show-fiat-txn-amounts-info": "Los montos de las transacciones se mostrarán en la moneda fiat seleccionada" } } } diff --git a/ui/common/localization/index.ts b/ui/common/localization/index.ts index aea06e1..23af053 100644 --- a/ui/common/localization/index.ts +++ b/ui/common/localization/index.ts @@ -11,6 +11,7 @@ import commonRW from './rw/common.json' import commonSO from './so/common.json' import commonSW from './sw/common.json' import commonTL from './tl/common.json' +import commonMY from './my/common.json' export const resources = { en: { @@ -52,4 +53,7 @@ export const resources = { am: { translation: commonAM, }, + my: { + translation: commonMY, + }, } diff --git a/ui/common/localization/my/common.json b/ui/common/localization/my/common.json new file mode 100644 index 0000000..aae094c --- /dev/null +++ b/ui/common/localization/my/common.json @@ -0,0 +1,771 @@ +{ + "words": { + "accept": "လက်ခံသည်", + "actions": "လုပ်ဆောင်ချက်များ", + "address": "လိပ်စာ", + "admin": "စီမံခန့်ခွဲသူ", + "amount": "ပမာဏ", + "approved": "အတည်ပြုခဲ့ပြီး", + "authorize": "လုပ်ပိုင်ခွင့်ရှိသည်", + "backup": "အရန်ဖိုင်များ", + "balance": "လက်ကျန်ငွေ", + "bitcoin": "ဘစ်ကွိုင်", + "cancel": "ပယ်ဖျက်သည်", + "canceled": "ပယ်ဖျက်ပြီး", + "chat": "စကားပြောသည်", + "community": "အဖွဲ့အစည်း", + "complete": "ပြီးမြောက်သည်", + "confirm": "အတည်ပြုသည်", + "confirmed": "အတည်ပြုပြီး", + "continue": "ဆက်လက်လုပ်ဆောင်သည်", + "copy": "ကူးယူသည်", + "currency": "ငွေကြေး", + "deposit": "စရံငွေ", + "details": "အသေးစိတ်အချက်အလက်", + "done": "ပြီးဆုံးသည်", + "email": "အီးမေးလ်", + "enjoy": "ပျော်ရွှင်သည်", + "error": "မှားယွင်းနေသည်", + "expired": "သက်တန်းကုန်ပြီ", + "failed": "မအောင်မြင်ပါ", + "federation": "ဖက်ဒရေးရှင်း", + "federations": "ဖက်ဒရေးရှင်းများ", + "fedimint": "ဖက်ဒီမင့်", + "fedimods": "ဖယ်ဒီ မော့တ်", + "fee": "ကျသင့်ငွေ", + "fees": "ကျသင့်ငွေများ", + "from": "မှ", + "general": "ယေဘုယျ", + "group": "အဖွဲ့", + "history": "သမိုင်း", + "home": "အိမ်", + "icon": "အိုင်ကွန်", + "important": "အရေးကြီးသော", + "invite": "ဖိတ်ခေါ်သည်", + "invited": "ဖိတ်ခေါ်ပြီး", + "join": "ပူးပေါင်းသည်", + "joined": "ပူးပေါင်းပြီး", + "language": "ဘာသာစကား", + "leave": "ထွက်ခွါသည်", + "lightning": "လိုက်တ်နင်", + "member": "အဖွဲ့ဝင်", + "members": "အဖွဲ့ဝင်များ", + "memo": "မှတ်စု", + "message": "စာ", + "messages": "စာများ", + "moderator": "ဆွေးနွေးမှုကိုဦးဆောင်သူ", + "next": "နောက်တစ်ခု", + "no": "မှားသည်", + "nostr": "နော့စ်တာ", + "notes": "မှတ်စုများ", + "okay": "အိုကေ", + "onchain": "ကွင်းဆက်", + "one": "တစ်ခု", + "optional": "ရွေးချယ်နိုင်သည်", + "paid": "ပေးပြီး", + "pay": "ပေးသည်", + "payments": "ငွေပေးချေမှုများ", + "pending": "ဆိုင်းငံ့ထားသည်", + "people": "လူများ", + "receive": "လက်ခံရရှိသည်", + "received": "လက်ခံရရှိပြီး", + "receiving": "လက်ခံရရှိနေသည်", + "refund": "ပြန်အမ်းငွေ", + "reject": "ငြင်းပယ်သည်", + "rejected": "ငြင်းပယ်ပြီး", + "remaining": "ကျန်ရှိနေသော", + "request": "တောင်းဆိုမှု", + "required": "လိုအပ်သော", + "retry": "ပြန်လည်ကြိုးစားသည်", + "sats": "sats", + "save": "သိမ်းထားသည်", + "scan": "စကန်", + "seen": "မြင်ပြီးဖြစ်သည်", + "send": "ပို့သည်", + "sent": "ပို့ပြီး", + "settings": "ဆက်တင်", + "share": "မျှဝေသည်", + "skip": "ကျော်သည်", + "status": "အခြေအနေ/အဆင့်အတန်း", + "stay": "နေသည်", + "submit": "တင်ပြသည်", + "time": "အချိန်", + "title": "ခေါင်းစဉ်", + "to": "သို့", + "transactions": "ငွေပေးချေမှုများ", + "two": "နှစ်", + "unknown": "အမည်မသိ", + "unsupported": "မထောက်ပံ့ထားသော", + "upload": "တင်ပြသည်", + "url": "url", + "URL": "URL", + "username": "အသုံးပြုသူအမည်", + "wallet": "ပိုက်ဆံအိတ်", + "withdraw": "ပိုက်ဆံထုတ်သည်", + "withdrawal": "ပိုက်ဆံထုတ်ခြင်း", + "yes": "မှန်သည်", + "you": "သင်" + }, + "phrases": { + "add-note": "မှတ်စုထည့်ပါ", + "allow-camera-access": "Camera သုံးခွင့်ပြုပါ", + "app-settings-security": "အပ်ပလီကေးရှင်း ဆက်တင် နှင့် လုံခြုံရေး", + "app-version": "Fedi Bravo ဗားရှင်း {{version}}", + "back-to-app": "အပ်ပလီကေးရှင်း သို့ပြန်သွားမည်", + "backup-your-wallet": "ဘစ်ကွိုင် လိပ်စာ", + "bitcoin-address": "ဘစ်ကွိုင် လိပ်စာ", + "bitcoin-address-created": "ဘစ်ကွိုင် လိပ်စာ ပြုလုပ်ပြီး", + "bitcoin-equivalent": "ဘစ်ကွိုင် နှင့် ညီမျှသော", + "camera-settings": "ကမ်မရာ ဆက်တင်", + "changes-may-not-be-saved": "သင်ပြုလုပ်ထားသော ပြောင်းလဲမှုများ မအောင်မြင်ပါ", + "click-for-more-details": "အသေးအစိတ်အချက်အလက်များအတွက် နှိပ်ပါ", + "connect-to-federation": "ဖယ်ဒရေးရှင်းအားစမ်းသပ်ရန် ချိတ်ဆက်ပါ", + "copied-bitcoin-address": "ဘစ်ကွိုင် လိပ်စာအား ကူးယူပြီးဖြစ်သည်", + "copied-ecash-token": "အီးကက်ရှ် တိုကင်ကို ကူးယူပြီးဖြစ်သည်", + "copied-lightning-request": "လိုက်တ်နင် တောင်းဆိုမှု ကိုကူးယူပြီးဖြစ်သည်", + "copied-member-code": "ဖယ်ဒီ အဖွဲ့ဝင် ကုဒ်ကိုကူးယူပြီးဖြစ်သည်", + "copied-to-clipboard": "Clipboard သို့ကူးယူသည်", + "copied-transaction-id": "ငွေးပေးချေမှု အိုင်ဒီ အားကူးယူပြီးဖြစ်သည်", + "display-currency": "ပြသထားသော ငွေကြေး", + "edit-note": "မှတ်စုများပြင်ဆင်ခြင်း", + "email-address": "အီးမေးလ် လိပ်စာ", + "expires-in": "သက်တန်းကုန်မည့်ရက်", + "failed-to-decode-invoice": "ပြေစာရယူရန် မအောင်မြင်ပါ", + "federation-fee": "ဖယ်ဒရေးရှင်း ကျသင့်ငွေ", + "fedi-fee": "Fedi ကျသင့်ငွေ", + "fee-details": "ကျသင့်ငွေအသေးစိတ်", + "generate-invoice": "ပြေစာ ပြုလုပ်ပါ", + "go-back": "ပြန်သွားပါ", + "hide-details": "အသေးစိတ်အချက်အလက်များကို ဖုံးကွယ်ပါ", + "hold-to-confirm": "အတည်ပြုရန် နှိပ်ပါ", + "i-understand": "နားလည်ပါသည်", + "invalid-federation-code": "မှားယွင်းနေသော ဖယ်ဒရေးရှင်း ကုဒ်ဖြစ်သည်", + "join-another-federation": "တခြား ဖယ်ဒရေးရှင်းကိုပူးပေါင်းပါ", + "last-seen": "နောက်ဆုံးတွေ့သည့်အချိန်", + "lightning-address": "လိုက်တ်နင် လိပ်စာ", + "lightning-network": "လိုက်တ်နင် ကွန်ရက်", + "lightning-request": "လိုက်တ်နင် တောင်းဆိုမှု", + "network-fee": "ကွန်ရက် အတွက် ကျသင့်ငွေ", + "new-member": "အဖွဲ့ဝင်အသစ်", + "no-transactions": "ငွေပေးချေမှု မရှိ", + "onchain-address": "ကွင်းဆက် လိပ်စာ", + "open-in-browser": "ဘရောက်ဆာ တွင်ဖွင့်ပါ", + "paste-from-clipboard": "Clipboard မှကူးယူခဲ့သည်", + "please-confirm": "ကျေးဇူးပြု၍ အတည်ပြုပါ", + "receive-pending": "လက်ခံရရှိမှုကို ဆိုင်းငံ့ထားသည်", + "received-bitcoin": "ဘစ်ကွိုင် လက်ခံရရှိပြီးဖြစ်သည်", + "refund-pending": "ပြန်အမ်းငွေရရှိခြင်းကို ဆိုင်းငံ့ထားသည်", + "reload-app": "အပ်ပလီကေးရှင်း ကိုပြန်စတင်ပါ", + "return-to-home": "မူလစာမျက်နှာ သို့ပြန်သွားပါ", + "save-changes": "ပြောင်းလဲမှုများကို သိမ်းမည်", + "sent-bitcoin": "ဘစ်ကွိုင်ပေးပို့ပြီးဖြစ်သည်", + "start-over": "အစကပြန်စသည်", + "terms-and-conditions": "စည်းကမ်းနှင့်သတ်မှတ်ချက်များ", + "transaction-id": "ငွေပေးချေခြင်း အိုင်ဒီ", + "transaction-received": "ငွေပေးချေမှု လက်ခံရရှိပါသည်", + "view-public-federations": "အများပိုင် ဖယ်ဒရေးရှင်းကို ကြည့်ပါ", + "yearly-fee": "နှစ်စဉ်ကျသင့်ငွေ", + "you-are-offline": "လက်ရှိတွင် အင်တာနက်သုံးစွဲမှုမရှိနေပါ" + }, + "errors": { + "bad-connection": "သင်၏ကွန်ရက်ချိတ်ဆက်မှုမတည်ငြိမ်ပါ။ နောက်ထပ် ကွန်ရက်တစ်ခုချိတ်ရန် ပြန်ကြိုးစားကြည့်ပါ။", + "browser-feature-not-supported": "သင်၏ ဘရောက်ဆာတွင် ဤအရာကို လုပ်၍မရပါ", + "camera-unavailable": "Camera အသုံးပြုခွင့် မအောင်မြင်ပါ", + "chat-connection-restoring": "စကားပြောခန်း ချိတ်ဆက်မှု မတည်ငြိမ်ပါ။ ပြန်လည်ရယူရန် ..", + "chat-connection-unhealthy": "စကားပြောခန်း ချိတ်ဆက်မှု မတည်ငြိမ်ပါ။ အပ်ပလီကေးရှင်းကိုပြန်ဝင်ရောက်ကြည့်ပါ။", + "chat-list-render-error": "သင့် စကားပြောခန်းများကို တင်ဆက်ရာတွင် အမှားအယွင်းတစ်ခု ကြုံတွေ့နေပါသည်", + "chat-member-not-found": "ဤအသုံးပြုသူအားရှာဖွေတွေ့ရှိခြင်းမရှိပါ", + "chat-message-render-error": "စာများကို တင်ဆက်ရာတွင် အမှားအယွင်းတစ်ခု ကြုံတွေ့နေပါသည်", + "chat-payment-failed": "စကားပြောခန်း ငွေပေးချေမှု အတွက် အပ်ဒိတ် မရရှိပါ", + "chat-unavailable": "ဤဖက်ဒရေးရှင်းတွင် စကားပြောခန်း ဆာဗာ မပါရှိပါ", + "failed-to-fetch-gateways": "Gateways ရယူခြင်းမအောင်မြင်ပါ", + "failed-to-fetch-guardian-approval": "အုပ်ထိန်းသူခွင့်ပြုချက် ရယူခြင်း မအောင်မြင်ပါ", + "failed-to-fetch-transactions": "ငွေလွှဲမှု ရယူခြင်း မအောင်မြင်ပါ", + "failed-to-generate-invoice": "ပြေစာ ပြုလုပ်ခြင်း မအောင်မြင်ပါ", + "failed-to-join-federation": "ဖက်ဒရေးရှင်းကိုပူးပေါင်းရန် မအောင်မြင်ပါ", + "failed-to-leave-federation": "ဖက်ဒရေးရှင်းမှ ထွက်ရန် မအောင်မြင်ပါ", + "failed-to-load-tos": "ဝန်ဆောင်မှု စည်းကမ်းများ ရယူခြင်း မအောင်မြင်ပါ", + "failed-to-pay-invoice": "ပြေစာ ပေးရန် မအောင်မြင်ပါ", + "failed-to-switch-gateways": "Gateways ပြောင်းလဲရန် မအောင်မြင်ပါ", + "get-nostr-pubkey-failed": "နော့စ်တာ pub ကီး ရယူရန် မအောင်မြင်ပါ", + "history-render-error": "ဤအရာကို တင်ဆက်ရာတွင် အမှားအယွင်းတစ်ခု ကြုံတွေ့နေပါသည်", + "insufficient-balance": "လက်ကျန်မလုံလောက်ပါ သင့်ပိုက်ဆံအိတ်တွင် {{balance}} သာရှိသည်", + "invalid-amount-max": "သင် {{verb}} လုပ်နိုင်သော အများဆုံးမှာ {{amount}}", + "invalid-amount-min": "သင်လုပ်နိုင်သော အနည်းဆုံး {{verb}} သည် {{amount}}", + "invalid-ecash-token": "eash တိုကင် မမှန်ကန်ပါ", + "invalid-federation-code": "ဖက်ဒရေးရှင်းကုဒ် မမှန်ကန်ပါ", + "invalid-group-code": "ဤသည် မှန်ကန်သော အဖွဲ့ကုဒ်မဟုတ်ပါ", + "invalid-username": "စာလုံး 21 လုံး သို့မဟုတ် ထို့ထက်နည်းရမည် ဖြစ်ပြီး စာလုံးအကြီးများ မပါဝင်နိုင်ပါ", + "no-lightning-gateways": "ဤအသုံးပြုသူအတွက် အလင်းဝင်ပေါက်များ မတွေ့ပါ", + "onchain-deposits-disabled": "ဤ ဖက်ဒရေးရှင်းအတွက် Onchain အပ်ငွေများကို ပိတ်ထားသည်", + "only-group-owners-can-change-name": "အဖွဲ့ပိုင်ရှင်များသာ အဖွဲ့အမည်ကို ပြောင်းလဲနိုင်သည်", + "please-force-quit-the-app": "အမှားအယွင်းတစ်ခုဖြစ်ပွားခဲ့သည်၊ ကျေးဇူးပြု၍ အက်ပ်ကိုပိတ်ပြီး ထပ်စမ်းကြည့်ပါ", + "receive-ecash-failed": "eash ကို လက်ခံရယူရန် မအောင်မြင်ပါ။", + "receives-have-been-disabled": "ဤအဖွဲ့ချုပ်အတွက် လက်ခံရရှိမှုများကို ပိတ်ထားသည်", + "recovery-failed": "ပြန်လည်ရယူခြင်း မအောင်မြင်ပါ၊ ကျေးဇူးပြု၍ ထပ်စမ်းကြည့်ပါ", + "sign-nostr-event-failed": "nostr အစီအစဉ်ကို လက်မှတ်ထိုးရန် မအောင်မြင်ပါ", + "unknown-error": "အမည်မသိ အမှားတစ်ခု ဖြစ်ပွားခဲ့သည်", + "username-already-exists": "ဤအသုံးပြုသူအမည် ရှိပြီးဖြစ်သည်။ သင့်အသုံးပြုသူအမည်ကို ပြန်လည်ရယူရန် သင့်ပိုက်ဆံအိတ်ကို ပြန်လည်ရယူပါ။", + "webln-canceled": "တောင်းဆိုချက်ကို ပယ်ဖျက်လိုက်ပါပြီ", + "webln-method-not-supported": "{{method}} ကို မပံ့ပိုးပါ", + "webln-payment-rejected": "ငွေပေးချေမှု ပယ်ဖျက်ပြီးပါပြီ", + "webln-payment-request-rejected": "ငွေပေးချေမှု တောင်းဆိုချက်ကို ပယ်ဖျက်ပြီးပါပြီ", + "you-have-already-joined": "သင် ဤဖယ်ဒရေးရှင်း ကိုဝင်ရောက်ပြီးဖြစ်သည်" + }, + "feature": { + "backup": { + "backup-social-recovery-file": "သင်၏ လူမှုရေးပြန်လည်ရယူမှုဖိုင် များကို အရန်ထားပါ", + "backup-to-google-drive": "သင်၏ ဂူးဂဲလ် ဒရိုက် ထဲသို့ အရန်ထားပါ", + "backup-wallet": "သင်၏ ပိုက်ဆံအိတ်ကို အရန်ထားပါ", + "backups-made": "အရန်ဖိုင်များ ပြုလုပ်ပြီးဖြစ်သည်", + "camera-access-information": "လူမှုရေးအရန်ကူးယူဖန်တီးရန်၊ အတည်ပြုခြင်းဗီဒီယိုကို မှတ်တမ်းတင်ရန် သင့်ကင်မရာသို့ ဖယ်ဒီအားဝင်ရောက်ခွင့်ပေးရန် လိုအပ်ပါသည်", + "choose-method": "အရန်ပြုလုပ်ရန်နည်းလမ်းကို ရွေးပါ", + "choose-method-instructions": "သင့်ပိုက်ဆံအိတ်ကို အရန်သိမ်းခြင်းသည် ဖယ်ဒီ ကို အသုံးပြုခွင့်ဆုံးရှုံးသွားပါက သင့်ပိုက်ဆံကို ပြန်လည်ရရှိရန် ကူညီပေးပါမည်။", + "cloud-backup": "ကလောက် အရန်ဖိုင်များ", + "cloud-backup-instructions": "သင်၏ ကလောက် သိုလှောင်မှုတွင် သင်၏ ပြန်လည်ရယူရေးဖိုင်၏ အရန်တစ်ခု ဖန်တီးပါ။ ဤဖိုင်ကို သူငယ်ချင်းနှစ်ယောက် သို့မဟုတ် မိသားစုနှင့်အတူ ကူးယူရန်လည်း လိုအပ်ပါလိမ့်မည်။", + "complete-social-backup": "လှုမှုရေး အရန်ဖိုင်များရယူခြင်း အတည်ပြုပါ", + "confirm-backup-video": "အရန် ဗီဒီယိုများ ရယူခြင်း အတည်ပြုပါ", + "creating-recovery-file": "ကုဒ်ဝှက်ထားသော ပြန်လည်ရယူရေးဖိုင်ကို ဖန်တီးနေသည်...", + "export-transactions-to-csv": "ငွေပေးငွေယူများကို CSV သို့ တင်ပို့ပါ။", + "file-backup": "အရန်ဖိုင်များ", + "hold-record-button": "မှတ်တမ်းခလုတ်ကို ဖိထားပြီး ပြောပါ", + "personal-backup": "ကိုယ်ရေးကိုယ်တာ အရန်ဖိုင်များ", + "personal-backup-instructions": "ပြန်လည်ရယူရေး စကားလုံး ၁၂ လုံးကို ချရေးပါ။ ဒါက အတွေ့အကြုံရှိတဲ့ အဖွဲ့ဝင်တွေအတွက်ပါ။", + "please-review-backup-video": "ကျေးဇူးပြု၍ အရန်ဗီဒီယိုကို ပြန်လည်သုံးသပ်ပါ။", + "press-record-button": "မှတ်တမ်းခလုတ်ကို နှိပ်ပြီး ပြောပါ", + "record-again": "မှတ်တမ်း ပြန်တင်ပါ", + "record-error": "သင့်ကင်မရာကို အသုံးပြုရာတွင် အမှားအယွင်းတစ်ခု ကြုံတွေ့ခဲ့ရသည်", + "record-video": "မှတ်တမ်း ဗီဒီယို", + "recovery-words": "ပြန်လည်ရယူထားသော စကားလုံးများ", + "recovery-words-instructions": "ဒီစကားလုံးတွေကို စာရွက်နဲ့ ဘောပင်နဲ့ ချရေးပါ။ ဤစကားလုံးများကို သင့်စက်ပေါ်တွင် ဒစ်ဂျစ်တယ်နည်းဖြင့်မသိမ်းဆည်းပါနှင့်။", + "review-face-confirmation": "ဤဗီဒီယိုတွင် ကျွန်ုပ်၏မျက်နှာကို ရှင်းရှင်းလင်းလင်းမြင်ရကြောင်း အတည်ပြုပါသည်။", + "review-voice-confirmation": "ဤဗီဒီယိုတွင် ကျွန်ုပ်၏အသံကို ရှင်းရှင်းလင်းလင်းကြားနိုင်သည်ဟု အတည်ပြုပါသည်။", + "save-file": "ဖိုင်များကို သိမ်းပါ", + "save-your-wallet-backup-file": "သင်၏ ပိုက်ဆံအိတ် အရန်ဖိုင်များကို သိမ်းပါ", + "save-your-wallet-backup-file-again": "တခြားတစ်နေရာတွင် သိမ်းပါ", + "save-your-wallet-backup-file-where": "သင့်ဖိုင်ကို မတူညီသောနေရာများတွင် သိမ်းဆည်းရန် အကြံပြုပါသည် (စာတိုပေးပို့ခြင်းအက်ပ်များ၊ အီးမေးလ်၊ cloud သိုလှောင်မှု စသည်ဖြင့်)", + "social-backup": "လူမှုရေးအရန်", + "social-backup-instructions": "သင့်အထောက်အထားကို သက်သေပြရန် ဗီဒီယိုတစ်ခု ရိုက်ကူးပါ။ သင့်အုပ်ထိန်းသူများကို ကောင်းစွာသိပါက ဤရွေးချယ်မှုသည် အကောင်းဆုံးဖြစ်သည်။", + "social-backup-processing-info-1": "ဤပြန်လည်ရယူရေးဖိုင်တွင် သင်၏ အရန်ဗီဒီယိုနှင့် သင်၏ဖက်ဒရေးရှင်း အကြောင်း အသေးစိတ်အချက်အလက်များပါရှိသည်။", + "social-backup-processing-info-2": "ပြန်လည်ရယူချိန်တွင် အုပ်ထိန်းသူများသည် သင်၏အထောက်အထားကို စစ်ဆေးမည်ဖြစ်သဖြင့် ဤဖိုင်တစ်ခုတည်းကို သင့်ငွေပြန်လည်ရယူရန်အတွက် အသုံးမပြုနိုင်ပါ", + "social-backup-video-prompt": "ဖယ်ဒီ!", + "start-personal-backup": "ကိုယ်ရေးကိုယ်တာ အရန်ဖိုင်များကိုစတင်ပါ", + "start-personal-backup-instructions": "ဘောပင်နှင့် စာရွက်ကိုရယူပြီး နောက်စခရင်တွင် ပြန်လည်ရယူရေးစကားလုံးများကို ချရေးပါ", + "start-recording": "မှတ်တမ်းယူခြင်းအားစတင်မည်", + "start-social-backup": "လူမှုရေး အရန်သိမ်းခြင်းကို စတင်ပါ", + "start-social-backup-instructions": "လူမှုရေးအရန်သိမ်းဆည်းမှုများသည် သင့်ပိုက်ဆံအိတ်ကို သူငယ်ချင်းများနှင့် မိသားစုများနှင့် လုံခြုံစွာ သိမ်းဆည်းနိုင်စေပြီး သင်ဝင်ရောက်ခွင့်ဆုံးရှုံးပါက ၎င်းကို ပြန်လည်ရယူရန် ဖက်ဒရေးရှင်းတာဝန်ရှိသူများမှ သင့်အား ကူညီဆောင်ရွက်ပေးပါသည်။", + "stop-recording": "မှတ်တမ်းယူခြင်းအား ရပ်တန့်ပါ", + "successfully-backed-up": "သင်၏ ဖယ်ဒီ ပိုက်ဆံအိတ် ကိုအရန်ယူထားခြင်းအောင်မြင်ပါသည်", + "video-file-too-large": "ဗီဒီယိုဖိုင် အရမ်းကြီးတယ်၊ ပိုတိုတဲ့ ဖိုင်ကို မှတ်တမ်းတင်ပါ" + }, + "bug": { + "description-label": "ပြဿနာအား အသေးစိတ်ရှင်းပြပါ", + "description-placeholder": "သင် ဘာကိုမျှော်လင့်ခဲ့သလဲ၊ အဲဒီအစား ဘာဖြစ်ခဲ့လဲ။", + "email-label": "အီးမေးလ် (ရွေးချယ်နိုင်သည်)", + "info-label": "ချို့ယွင်းချက်အစီရင်ခံစာနှင့်အတူ ဖက်ဒရေးရှင်းနှင့် အသုံးပြုသူအမည်ကို ပေးပို့ပါ", + "log-disclaimer": "ဖယ်ဒီ ဆော့ဖ်ဝဲရေးသားသူများက ပြဿနာကို အဖြေရှာရာတွင် ကူညီရန် အက်ပ်လီကေးရှင်းမှတ်တမ်းများကို သင့်အစီရင်ခံစာတွင် ပါ၀င်မည်ဖြစ်သည်", + "report-a-bug": "Bug တစ်ခုကို သတင်းပို့ပါ", + "screenshot-label": "စခရင်ရှော့များနှင့် မှတ်တမ်းများကိုတင်ပြပါ", + "submit-generating-data": "အစီအရင်ခံစာအချက်အလက်များကို ပြုလုပ်နေသည်", + "submit-submitting-report": "အစီရင်ခံစာ ကိုတင်ပြနေသည်", + "submit-uploading-data": "အစီရင်ခံစာအချက်အလက်များကို တင်ပြနေသည်", + "success-subtitle": "ကျွန်ပ်တို့အလုပ်လုပ်ပါတော့မည်", + "success-title": "သင်၏ Bug အစီရင်ခံစာအတွက် ကျေးဇူးတင်ပါသည်" + }, + "chat": { + "add-admin": "စီမံခန့်ခွဲသူ ကိုထည့်ပါ", + "add-an-avatar": "အဗာတာ ကိုထည့်ပါ", + "add-user-as-an-admin": "{{username}} ကိုစီမံခန့်ခွဲသူအဖြစ်ထည့်ပါ", + "added-admin-to-group": "{{username}} ကိုစီမံခန့်ခွဲသူအဖြစ်ခည့်ပြီးဖြစ်သည်", + "admin-settings": "စီမံခန့်ခွဲသူ ဆက်တင်", + "admin-settings-instructions": "ဤအဖွဲ့ဝင်များသည် ထုတ်လွှင့်ခြင်းသီးသန့်အဖွဲ့တွင် စာပို့နိုင်သည်", + "broadcast-admin-instructions": "ဤအထွဲ့ဝင်များသည် ထုတ်လွှင့်ခြင်းအဖွဲ့တွင် စာပို့နိုင်သည်", + "broadcast-admin-settings": "ထုတ်လွှင့်ခြင်း စီမံခန့်ခွဲသူ ဆက်တင်", + "broadcast-admins": "ထုတ်လွှင့်ခြင်း စီမံခန့်ခွဲသူ", + "broadcast-no-message": "စီမံခန့်ခွဲသူများ စာများ ထုတ်လွှင့်ခြင်းမရှိသေးပါ ဆက်လက်စောင့်နေပေးပါ", + "broadcast-only": "ထုတ်လွှင့်ခြင်းသီးသန့်", + "camera-access-information": "အဖွဲ့ကို ဝင်ရောက်ရန်အတွက် ဖိတ်ကြားချက်လင့်ခ်ကို စကန်ဖတ်ရန် Fedi အား သင်၏ ကမ်မရာအသုံးပြုခွင့်ပေးရန်လိုအပ်မည်", + "change-group-name": "အဖွဲ့နာမည်ပြောင်းပါ", + "change-role": "အခန်းကဏ္ဍ ပြောင်းပါ", + "change-role-failure": "အခန်းကဏ္ဍ ပြောင်းခြင်း မအောင်မြင်ပါ", + "change-role-success": "အခန်းကဏ္ဍ ပြောင်းခြင်း အောင်မြင်ပါသည်", + "changing-broadcast-not-supported": "ထုတ်လွှင့်ခြင်း ဆက်တင် ပြောင်းလဲခြင်း ပြုလုပ်၍မရသေးပါ။ အဖွဲ့အသစ်ပြုလုပ်ပါ။", + "chat-invite": "စကားပြောခန်းသို့ ဖိတ်ခေါ်ခြင်း", + "click-to-join-group": "ဤအဖွဲ့တွင်ပါဝင်ရန် နှိပ်ပါ", + "confirm-add-admin-to-group": "{{username}} ကို စီမံခန့်ခွဲသူအဖြစ် ထည့်လိုသည်မှာ သေချာပါသလား။ သူတို့သည် ဤအဖွဲ့တွင် မက်ဆေ့ချ်များ ပေးပို့နိုင်မည်ဖြစ်သည်။", + "confirm-remove-admin-from-group": "စီမံခန့်ခွဲသူအဖြစ်မှ {{username}} ကို ဖယ်ရှားလိုသည်မှာ သေချာပါသလား။ သူတို့သည် ဤအဖွဲ့တွင် မက်ဆေ့ချ်များ ပေးပို့နိုင်မည်မဟုတ်သော်လည်း ၎င်းတို့၏ယခင်ပေးပို့ထားသော မက်ဆေ့ချ်များသည် ကျန်ရှိနေမည်ဖြစ်သည်။", + "copied-group-invite-code": "အဖွဲ့သို့ဖိတ်ခေါ်ခြင်းကုဒ်အားကူးယူးပြီးဖြစ်သည်", + "create-a-display-name": "ပြသထားမည့် အမည် ပြုလုပ်ပါ", + "create-a-group": "အဖွဲ့တစ်ခုပြုလုပ်ပါ", + "create-group": "အဖွဲ့ ပြုလုပ်ပါ", + "create-or-join-a-new-group": "အဖွဲ့အသစ်တစ်ခု ပြုလုပ်ပါ (သို့) အဖွဲ့အသစ်သို့ ဝင်ရောက်ပါ", + "disappearing-messages": "ပျောက်ကွယ်သွားသော စာများ", + "display-name": "ပြသထားသော အမည်", + "display-name-guidance": "နောက်မှ ပြန်လည်ပြောင်းလဲနိုင်ပါသည်", + "edit-group": "အဖွဲ့ကို တည်းဖြတ်ပါ", + "enter-a-username": "အသုံးပြုသူအမည်အား ဖြည့်သွင်းပါ", + "enter-display-name": "ပြသထားသော အမည် အားဖြည့်သွင်းပါ", + "fedi-community": "ဖယ်ဒီ အဖွဲ့အစည်း", + "fedi-community-message-preview": "ဖယ်ဒီ မှကြိုဆိုပါတယ်! ဖယ်ဒီ အပ်ပလီကေးရှင်းအတွင်းမှ အဖြစ်အပျက်များကို ဤချယ်နယ်မှ တင်ပြသွားပါမည်", + "go-to-direct-chat": "တိုက်ရိုက်စကားပြောခန်းသို့သွားမည်", + "group-invite": "အဖွဲ့သို့ ဖိတ်ခေါ်ခြင်း", + "group-name": "အဖွဲ့အမည်", + "group-not-found": "အဖွဲ့အားရှာ့မတွေ့ပါ", + "invalid-group": "ဤစကားပြောခန်းသည် မမှန်ကန်ပါ", + "invalid-member": "ဤစကားပြောခန်း အဖွဲ့ဝင်သည် မမှန်ကန်ပါ", + "invite-to-group": "အဖွဲ့သို့ ဖိတ်ခေါ်သည်", + "join-a-group": "အဖွဲ့သို့ ဝင်ရောက်သည်", + "leave-group": "အဖွဲ့မှ ထွက်သည်", + "leave-group-confirmation": "အဖွဲ့မှထွက်မှာသေချာပါသလား။ အဖွဲ့အတွင်းရှိ စာများပျက်သွားပါမည်။", + "member-not-found": "ဤ အသုံးပြုသူအမည်ဖြင့် အဖွဲ့ဝင်အားရှာမတွေ့ပါ '{{username}}'", + "need-registration-description": "သင်၏ ပြသထားသော အမည်ကို တခြား အသုံးပြုသူများနှင့် ပေးပို့လက်ခံနိုင်အောင် ပြုလုပ်ထားပါ", + "need-registration-title": "စကားပြောရန် အဆင်သင့်ပြင်ထားပါ", + "new-chat": "စကားပြောခန်း အသစ်", + "new-group": "အဖွဲ့ အသစ်", + "new-message": "စာ အသစ်", + "new-messages": "စာ အသစ်များ", + "no-admins": "ထုတ်လွှင့်ခြင်း စီမံခန့်ခွဲသူ မရှိသေးပါ", + "no-messages": "ဤအခန်းတွင် စကားပြောထားခြင်းမရှိသေးပါ", + "no-one-is-in-this-group": "ဤအခန်းတွင် အဖွဲ့ဝင်များမရှိသေးပါ", + "no-users-found": "အသုံးပြုသူ မတွေ့ပါ", + "open-camera-scanner": "ကမ်မရာ စကန်နာ အားဖွင့်ပါ", + "other-sent-payment": "{{name}} က {{recipient}} {{fiat}} ({{amount}}) {{memo}} ပို့သည်", + "paid-by-name": "{{name}} မှပေးချေပြီးဖြစ်သည်", + "paste-group-invite": "အဖွဲ့ ကမ်းလှမ်းချက် အားကူးယူပြီး", + "register-a-username": "အသုံးပြုသူအမည်ကို မှတ်ပုံတင်ပါ", + "removed-admin-from-group": "စီမံခန့်ခွဲသူအဖြစ် {{username}} ကို ဖယ်ရှားခဲ့သည်", + "room-settings": "အခန်းဆက်တင်များ", + "scan-chat-invite": "စကားပြောခန်း ဖိတ်ကြားချက်ကို စကင်ဖတ်ပါ", + "scan-group-invite": "အဖွဲ့ ဖိတ်ကြားချက်ကို စကင်ဖတ်ပါ", + "scan-member-code-notice": "သင်၏အသုံးပြုသူအမည်ကိုမျှဝေရန် QR ကိုပြပါ", + "select-or-start": "စကားပြောခန်းတစ်ခုကို ရွေးပါ သို့မဟုတ် စကားပြောခန်းအသစ်တစ်ခု စတင်ပါ", + "send-a-message-to": "{{name}} သို့ စာတိုတစ်စောင် ပို့ပါ", + "show-history-to-new-members": "အဖွဲ့ဝင်အသစ်များကို မှတ်တမ်းပြပါ", + "start-the-conversation": "စကားဝိုင်းကို စတင်ပါ", + "they-requested-payment": "{{name}} က {{fiat}} ({{amount}}) {{memo}} တောင်းဆိုထားသည်", + "they-sent-payment": "{{name}} က {{fiat}} ({{amount}}) {{memo}} သင့်ကို ပို့လိုက်သည်", + "try-inviting-someone": "တစ်စုံတစ်ယောက်ကို ဖိတ်ခေါ်ကြည့်ပါ", + "type-to-search-members": "ဤအဖွဲ့ရှိ အဖွဲ့ဝင်များကို ရှာဖွေရန် ရိုက်ထည့်ပါ", + "unknown-member": "အမည်မသိအဖွဲ့ဝင်", + "upgrade-chat": "စကားပြောခန်းကို အဆင့်မြှင့်ပါ", + "upgrade-chat-guidance": "ကျွန်ုပ်တို့သည် Fedi ချတ်ကို ပိုမိုလုံခြုံပြီး အသိုက်အဝန်းများစွာရှိလူများအတွက် အသုံးပြုနိုင်အောင် ပြုလုပ်ထားပါသည်", + "upgrade-chat-item-subtitle-1": "Fedi တွင်မည်သူမဆိုနှင့်စကားပြောပါ", + "upgrade-chat-item-subtitle-2": "အားလုံးအတွက် Npubs", + "upgrade-chat-item-subtitle-3": "အဆုံးမှအဆုံး", + "upgrade-chat-item-subtitle-4": "bitcoin ပေးပို့ခြင်းနှင့်လက်ခံခြင်း", + "upgrade-chat-item-subtitle-5": "မကြာခင် ™", + "upgrade-chat-item-title-1": "အဖွဲ့အစည်းပေါင်းစုံ စကားပြောခန်းများ", + "upgrade-chat-item-title-2": "တစ်ခုတည်း ဖော်ပြသော အမည်", + "upgrade-chat-item-title-3": "ကုဒ်ဝှက်ထားသော DMs", + "upgrade-chat-item-title-4": "လွယ်ကူသောငွေပေးချေမှုများ", + "upgrade-chat-item-title-5": "အများကြီးထပ်လာရန်ရှိသည်!", + "view-group": "အဖွဲ့ကိုကြည့်ပါ", + "view-shared-media": "မျှဝေထားသောမီဒီယာကိုကြည့်ပါ", + "waiting-for-network": "ကွန်ရက်ကို စောင့်နေသည်...", + "you-requested-payment": "သင်သည် {{fiat}} ({{amount}}) {{memo}} ကိုတောင်းဆိုထားသည်", + "you-sent-payment": "သင်သည် {{fiat}} ({{amount}}) {{memo}} ကို ပေးပို့ခဲ့သည်" + }, + "developer": { + "developer-mode-activated": "သင်သည် ယခုအခါ ဆော့ဖ်ဝဲရေးသားသူ ဖြစ်လာပါပြီ", + "developer-mode-deactivated": "မင်းက ဆော့ဖ်ဝဲရေးသားသူ တစ်ယောက်မဟုတ်တော့ဘူး...", + "download-logs": "ဒေါင်းလုဒ်မှတ်တမ်းများ", + "export-transactions-csv": "ငွေပေးငွေယူ CSV ကို ထုတ်ယူပါ", + "logs": "မှတ်တမ်းများ", + "nightly": "ညစဉ်ညတိုင်း", + "share-logs": "မှတ်တမ်းများမျှဝေပါ", + "share-state": "အပ်ပလီကေးရှင်း state ကိုမျှဝေပါ" + }, + "federations": { + "add-federation": "ဖက်ဒရေးရှင်း တစ်ခုထည့်ပါ", + "camera-access-information": "ဖက်ဒရေးရှင်းတစ်ခုတွင်ပါဝင်ရန်၊ ဖယ်ဒရေးရှင်းဖိတ်ကြားချက်ကုဒ်ကို စကင်န်ဖတ်ရန် သင့်ကင်မရာသို့ Fedi ဝင်ရောက်ခွင့်ပေးရန် လိုအပ်ပါသည်", + "copied-federation-invite": "ဖက်ဒရေးရှင်းဖိတ်ကြားချက်လင့်ခ်ကို ကူးယူထားသည်", + "enter-federation-code": "ဖက်ဒရေးရှင်းကုဒ်ကို ထည့်ပါ", + "federation-details": "ဖက်ဒရေးရှင်း အသေးစိတ်", + "federation-invite": "ဖက်ဒရေးရှင်း ဖိတ်ခေါ်ချက်", + "federation-terms": "ဖက်ဒရေးရှင်း စည်းမျဥ်း", + "invite-members": "ဖက်ဒေးရေးရှင်းကို ဖိတ်ကြားပါ", + "join-federation": "ဖက်ဒရေးရှင်းအသစ်နှင့်ပူးပေါင်းပါ", + "leave-federation": "ဖက်ဒရေးရှင်း မှထွက်ပါ", + "leave-federation-confirmation": "ဤအဖွဲ့ချုပ်မှ ထွက်ခွာလိုသည်မှာ သေချာပါသလား", + "leave-federation-withdraw-first": "မထွက်ခွာမီ သင့်ငွေများကို ထုတ်ယူရပါမည်", + "leave-federation-withdraw-pending-stable-first": "သင်၏ {{currency}} ထုတ်ယူမှုကို လုပ်ဆောင်ဆဲဖြစ်သည်။ ကျေးဇူးပြု၍ 10 မိနစ်အကြာတွင် ထပ်စမ်းကြည့်ပါ။", + "leave-federation-withdraw-stable-first": "မထွက်ခွာမီ သင်၏ {{currency}} လက်ကျန်အားလုံးကို ရုပ်သိမ်းရပါမည်", + "paste-federation-code": "ဖက်ဒရေးရှင်းကုဒ်ကို ကူးထည့်ပါ", + "paste-federation-code-instead": "ဤအစား ဖက်ဒရယ်ကုဒ်ကို ကူးထည့်ပါ", + "scan-federation-invite": "ဖက်ဒရေးရှင်း ဖိတ်ကြားချက်ကို စကင်ဖတ်ပါ" + }, + "fedimods": { + "add-a-mod": "Mod တစ်ခုထည့်ပါ", + "add-fedi-mod": "ဖယ်ဒီ Mod ကိုထည့်ပါ", + "add-mods-homescreen": "သင်၏ ဖယ်ဒီ ပင်မစခရင်တွင် Mods ကိုထည့်ပါ။", + "debug-mode": "ဖယ်ဒီ Mod Debug မုဒ်", + "debug-mode-info": "ဖယ်ဒီ Mods ကိုဖွင့်သောအခါတွင် dev tool (Eruda) ကိုဘရောက်ဆာထဲသို့ဖြည့်သွင်းပါ", + "enter-amount-to-withdraw": "{{fediMod}} မှ ထုတ်ယူရန် ပမာဏကို ထည့်ပါ", + "fedi-mods": "ဖယ်ဒီ Mods", + "leave-page": "စာမျက်နှာမှ ထွက်မလား။", + "leave-page-confirmation": "ဤစာမျက်နှာမှ ထွက်လိုသည်မှာ သေချာပါသလား။ အပြောင်းအလဲများကို မသိမ်းဆည်းနိုင်ပါ။", + "login-failed": "လော့ဂ်အင်မအောင်မြင်ပါ", + "login-to": "အကောင့်ဝင်ပါ", + "mod-title": "Mod ခေါင်းစဉ်", + "payment-request": "{{fediMod}} ထံမှ ငွေပေးချေမှု တောင်းဆိုချက်", + "stable-balance-enabled": "တည်ငြိမ်သောလက်ကျန်", + "stable-balance-enabled-info": "sats များကို တည်ငြိမ်သောငွေကြေး အဖြစ်ပြောင်းနိုင်ရန်ပြုလုပ်ပါ", + "wants-to-pay-you": "{{fediMod}} က သင့်အား ပေးဆောင်လိုပါသည်", + "wants-you-to-pay": "{{fediMod}} က သင့်အား ပေးဆောင်စေလိုပါသည်", + "your-mods": "သင်၏ Mods" + }, + "fees": { + "guidance-ecash": "တော်လိုက်တာ! သင်ရဲ့ ဖက်ဒရေးရှင်းအတွင်းမှ သူငယ်ချင်းများကို ပိုက်ဆံပေးပို့ခြင်းကပိုတန်ပြီးပိုလွယ်ပါတယ် – ပြီးတော့ ပိုပြီးလည်းမိုက်တယ်နော် 😎", + "guidance-lightning": "🤑 အခကြေးငွေနဲ့ ငွေစုချင်ပါသလား။ ဤအစား Chat သို့ ပို့ပါ။", + "guidance-stable-balance": "*ဤလက်ကျန်ငွေကို တစ်နှစ်ကြာထိန်းထားရန် သင်ပေးချေနိုင်သည့် အများဆုံးပမာဏဖြစ်သည်။ ဤထက်လည်းနည်းနိုင်သည်! 😉" + }, + "nostr": { + "kind-application-data": "အပလီကေးရှင်းအလိုက် အချက်အလက်", + "kind-authentication": "Nostr ဖြင့် ဝင်ရောက်ပါ", + "kind-connect": "Nostr ချိတ်ဆက်ပါ", + "kind-default": "Nostr မက်ဆေ့ခ်ျ", + "kind-encrypted-dm": "ကုဒ်ဝှက်ထားသော မက်ဆေ့ဂျ်", + "kind-highlight": "ဟိုက်လိုက်", + "kind-metadata": "မက်တာဒေတာ", + "kind-note": "စာသားအတိုမှတ်စု", + "kind-reaction": "တုံ့ပြန်ခြင်း", + "kind-repost": "ပြန်တင်သည်", + "kind-zap": "Zap", + "kind-zap-request": "Zap တောင်းဆိုချက်", + "log-in-to-mod": "{{method}} ဖြင့် {{fediMod}} သို့ ဝင်ရောက်ပါ", + "wants-you-to-sign": "{{fediMod}} က သင့်အား လက်မှတ်ထိုးစေလိုပါသည်" + }, + "omni": { + "action-enter-ln-address": "လိုက်တ်နင်လိပ်စာကို ထည့်ပါ", + "action-enter-text": "စာသားရိုက်ထည့်ပါ", + "action-enter-username": "အသုံးပြုသူအမည်ထည့်ပါ", + "action-enter-username-or-ln": "အသုံးပြုသူအမည် / လိုက်တ်နင် လိပ်စာထည့်ပါ", + "action-paste": "ကူးယူပါ", + "action-scan": "QR ကိုစကင်န်ဖတ်ပါ", + "action-upload": "QR ပုံ အပ်လုဒ်လုပ်ပါ", + "camera-permission-denied": "ကင်မရာခွင့်ပြုချက်များကို ပြောင်းလဲရန် ဆက်တင်များသို့ သွားပါ", + "camera-permission-request": "စကင်န်ဖတ်ရန် ကင်မရာဝင်ရောက်ခွင့်ကို ခွင့်ပြုပါ", + "confirm-ecash-token": "ဤသည်မှာ eCash တိုကင်တစ်ခုဖြစ်သည်၊ ၎င်းကို သင်ရွေးယူလိုပါသလား။", + "confirm-federation-invite": "ဒါက ဖက်ဒရေးရှင်းရဲ့ ဖိတ်ကြားချက်ပါ၊ ပါဝင်ချင်ပါသလား။", + "confirm-fedi-chat": "ဒါက စကားပြောခန်းလင့်ခ်ပါ၊ မင်း အဲဒီကို သွားချင်လား။", + "confirm-lightning-pay": "ဤသည်မှာ လိုက်တ်နင် ငွေပေးချေမှု တောင်းဆိုချက်ဖြစ်ပြီး ၎င်းကို ပေးဆောင်လိုပါသလား။", + "confirm-lightning-withdraw": "ဤသည်မှာ လိုက်တ်နင် ငွေထုတ်ခြင်းဖြစ်သည်။ ငွေထုတ်လိုပါသလား။", + "confirm-lnurl-auth": "လိုက်တ်နင် {{domain}} သို့ဝင်ရောက်လိုပါသလား", + "confirm-onchain-pay": "ဤသည်မှာ ဘစ်ကွိုင် ကွင်းဆက် ငွေပေးချေမှုဖြစ်သည်။ ငွေပေးချေလိုပါသလား။", + "confirm-website-url": "ဤသည်မှာ ဝက်ဘ်ဆိုဒ် လင့်ခ်ဖြစ်သည် သင်၏ဘရောက်ဆာတွင်ဖွင့်လိုပါသလား", + "search-no-history-header": "အသုံးပြုသူနှင့်အတူ မှတ်တမ်းမရှိပါ", + "search-no-results": "“{{query}}” နှင့် ကိုက်ညီသော အသုံးပြုသူ မရှိပါ", + "search-placeholder-ln-address": "လိုက်တ်နင်လိပ်စာကို ထည့်ပါ", + "search-placeholder-username": "အသုံးပြုသူအမည်ကို ထည့်သွင်းပါ", + "search-placeholder-username-or-ln": "အသုံးပြုသူအမည် သို့မဟုတ် လိုက်တ်နင်လိပ်စာကို ထည့်သွင်းပါ", + "unsupported-bolt12": "BOLT 12 ကမ်းလှမ်းချက်များကို ပံ့ပိုးမထားပါ။ စိတ်မကောင်းပါ!", + "unsupported-legacy-chat": "ဤစကားပြောခန်း QR သည် ပံ့ပိုးမပေးတော့သော အပ်ပလီကေးရှင်းဗားရှင်းတစ်ခုမှဖြစ်သည်။ စိတ်မကောင်းပါ!", + "unsupported-no-federation": "အဖွဲ့ချုပ်သို့မ၀င်မီ ထိုအရာကိုသင်အသုံးမပြုနိုင်ပါ။ ထိုအစား ဖက်ဒရေးရှင်းဖိတ်ကြားချက်ကို စကင်ဖတ်ကြည့်ပါ။", + "unsupported-on-chain": "On-chain ဘစ်ကွိုင် လိပ်စာများကို မပံ့ပိုးရသေးပါ။ စိတ်မကောင်းပါ!", + "unsupported-unknown": "ဟမ်၊ အဲဒါက ကျွန်တော်တို့ အသိအမှတ်ပြုတဲ့ ဖော်မတ်မဟုတ်ပါဘူး။ စိတ်မကောင်းပါ!" + }, + "onboarding": { + "by-clicking-i-accept": "ကျွန်ုပ်လက်ခံသည်' ကိုနှိပ်ခြင်းဖြင့် သင်သည် {{tos_url}} ရှိ ဝန်ဆောင်မှုစည်းမျဉ်းများကို သဘောတူပါသည်။", + "by-clicking-you-agree-user-agreement": "အသင်းချုပ်တွင်ပါဝင်ရန်' ကိုနှိပ်ခြင်းဖြင့် သင်သည် အသုံးပြုသူသဘောတူညီချက် ကို သဘောတူပါသည်။", + "chat-earn-save-spend": "စကားပြောပါ၊ ဝင်ငွေရှာပါ၊ ချွေတာပြီး သင့်အဖွဲ့အစည်းနှင့် သီးသန့်ငွေသုံးပါ", + "community-first": "အဖွဲ့အစည်း ပထမ", + "continue-to-fedi": "ဖယ်ဒီ ကိုဆက်လက်အသုံးပြုပါ", + "create-username": "အသုံးပြုသူအမည် ပြုလုပ်ပါ", + "create-your-username": "သင်၏ အသုံးပြုသူအမည်ကိုပြုလုပ်ပါ", + "earn-and-save": "ရရှိပြီး သိမ်းဆည်းပါ", + "enter-username": "အသုံးပြုသူအမည်ကို ဖြည့်ပါ", + "greeting-instructions": "ယခု သင်သည် ငွေပို့နိုင်၊ သင့်အဖွဲ့အစည်းနှင့်ဆက်သွယ်နိုင်ပြီး ဖယ်ဒီ နှင့် အခြားအရာများကိုလည်းလုပ်ဆောင်နိုင်မည်ဖြစ်သည်", + "guidance-1": "စကားပြောပါ၊ ဝင်ငွေရှာပါ၊ ချွေတာပြီး သင့်အဖွဲ့အစည်းနှင့် သီးသန့်ငွေသုံးပါ", + "guidance-2": "ဖယ်ဒီ သည် သင့်အဖွဲ့အစည်းကိုအသုံးပြုပြီး သင်၏ငွေကို လွယ်ကူစွာအရန်သိမ်းရန်၊ လုံခြုံစွာသိမ်းရန်၊ ပြည်တွင်းငွေသို့ပြောင်းရန် သို့ အကူအညီရယူရန် ဆောင်ရွက်ပေးသည်", + "guidance-3": "သင်သည် ကိုယ်ပိုင်လက်ကျန်ငွေသိမ်းဆည်းခြင်းများ၊ ငွေပေးချေမှုများနှင့် ဆက်သွယ်ခြင်းများကို ဖယ်ဒီ ဖြင့်လွယ်ကူစွာလုပ်နိုင်မည်ဖြစ်သည်", + "guidance-4": "ဖယ်ဒီ သည် ဘစ်ကွိုင်နှင့် လိုက်တ်နင် ကွန်ရက်ကိုအသုံးပြုပြီး တစ်ကမ္ဘာလုံးမှ ကိုယ့်နည်းကိုယ့်ဟန်ဖြင့် ပိုက်ဆံရရှိနိုင်မည့် အခွင့်အလမ်းများကိုရှာဖွေပေးလျက်ရှိသည်", + "guidance-public-federations": "Awesome Fedimint စာရင်းမှ အဖွဲ့အစည်းတစ်ခုကို စမ်းကြည့်ပါ", + "i-accept": "ကျွန်ပ် လက်ခံပါသည်", + "i-do-not-accept": "ကျွန်ုပ် လက်မခံပါ", + "join-new-member": "အဖွဲ့ဝင်အသစ်အဖြစ်ဝင်ရောက်မည်", + "join-returning-member": "ပြန်လည်ရောက်ရှိလာသော အဖွဲ့ဝင်ဖြစ်ပါသည်", + "new-users-disabled-notice": "ဝမ်းနည်းပါသည်၊ ဤဖက်ဒရေးရှင်းသည် သုံးစွဲသူအသစ်များကို လက်မခံပါ", + "nice-to-meet-you": "တွေ့ရတာဝမ်းသာပါတယ် {{username}}", + "simple-and-private": "ရိုးရှင်း၍ သီးသန့်ဖြစ်သော", + "terms-and-conditions": "စည်းကမ်းနှင့်သက်မှတ်ချက်များ", + "unsupported-notice": "ဤဖက်ဒရေးရှင်း မရှိတော့ပါ ပူးပေါင်းရန်မဖြစ်နိုင်ပါ", + "username-guidance": "ဒါကို နောက်မှ ပြောင်းနိုင်ပါတယ်", + "username-instructions": "သင်၏ အသုံးပြုသူအမည် သည် အခြား အဖွဲ့ဝင်များမှ သင့်ကိုမှတ်မိမည့် အမည်ဖြစ်သည်", + "welcome-back-to-federation": "{{federation}} မှ ကြိုဆိုပါတယ်", + "welcome-instructions-new": "ဖက်ဒရေးရှင်း ၏အဖွဲ့ဝင်အသစ်အနေဖြင့်၊ သင်သည် ပိုက်ဆံအိတ်အသစ်ကို ရရှိမည်ဖြစ်သည်", + "welcome-instructions-returning": "ဖက်ဒရေးရှင်း ၏ အဖွဲ့ဝင်တစ်ဦးအနေဖြင့် သင့်ပိုက်ဆံအိတ်ကို ပြန်လည်ရယူပါမည်။ မိနစ်အနည်းငယ် ကြာနိုင်သည်။", + "welcome-instructions-unknown": "အဖွဲ့ဝင်အသစ်များသည် ပိုက်ဆံအိတ်အသစ်တစ်လုံးကို ရရှိသည်။ ပြန်လည်ရောက်ရှိလာသော အဖွဲ့ဝင်များသည် ၎င်းတို့၏ ပိုက်ဆံအိတ်ဟောင်းကို အလိုအလျောက် ပြန်လည်ရရှိမည်ဖြစ်သည်။", + "welcome-to-federation": "{{ဖယ်ဒရေးရှင်း}} မှကြိုဆိုပါသည်။", + "welcome-to-fedi": "ဖယ်ဒီ ဘရာဗို မှကြိုဆိုပါသည်။" + }, + "parser": { + "unrecognized": "အသိအမှတ်ပြုမထားသော အချက်အလက်ဖော်မတ်", + "unsupported-bolt11-zero-amount": "သုညပမာဏ လိုက်တ်နင် ငွေတောင်းခံလွှာများကို ပံ့ပိုးမထားပါ။ ပမာဏတစ်ခုဖြင့် ပြေစာအသစ်တစ်ခုကို ထုတ်ပြီး ထပ်စမ်းကြည့်ပါ", + "unsupported-lnurl": "ပံ့ပိုးမထားသော LNURL အမျိုးအစား '{{type}}'" + }, + "permissions": { + "allow-camera-description": "QR ကုဒ်များကို စကင်န်ဖတ်ခြင်း၊ အသုံးပြုသူအမည်များကို ချတ်လုပ်ခြင်း၊ ငွေပို့ခြင်းနှင့် အခြားအရာများ ပြုလုပ်ပါ", + "allow-camera-title": "ကင်မရာအသုံးပြုခွင့်ကို ခွင့်ပြုပါ", + "allow-notifications-description": "စကားပြောခန်းမက်ဆေ့ခ်ျအသစ်များ၊ ငွေပေးချေမှုများနှင့် ကြေငြာချက်များ", + "allow-notifications-title": "အကြောင်းကြားချက်များကို ကြည့်ရှုခွင့်ပြုပါ", + "update-later-disclaimer": "၎င်းကို နောက်ပိုင်းတွင် အပ်ဒိတ်လုပ်နိုင်ပါသည်" + }, + "pin": { + "back-up-your-account": "သင့်အကောင့်ကို အရန်သိမ်းပါ", + "backup-notice": "သင့် PIN ကို မေ့သွားပါက၊ သင်၏ အရန်သိမ်းမှုသည် သင့်အကောင့်ကို ပြန်လည်ရယူရန် တစ်ခုတည်းသော နည်းလမ်းဖြစ်သည်", + "change-pin": "PIN ပြောင်းပါ", + "create-a-pin": "PIN ပြုလုပ်ပါ", + "create-new-pin": "PIN အသစ်ပြုလုပ်ပါ", + "enter-current-pin": "လက်ရှိ PIN ကိုဖြည့်ပါ", + "enter-pin": "PIN ကိုဖြည့်ပါ", + "pin-access": "PIN ခွင့်ပြုချက်", + "pin-doesnt-match": "PIN ကိုက်ညီမှုမရှိပါ", + "pin-setup-successful": "PIN ထားခြင်း အောင်မြင်ပါသည်", + "re-enter-pin": "PIN ပြန်လည်ဖြည့်သွင်းပါ", + "unlocking-fedi-app": "ဖယ်ဒီ အပ်ပလီကေးရှင်း ကိုလော့ခ်ဖြည်ပါ" + }, + "popup": { + "ended": "ပြီးဆုံးသည်", + "ended-description": "ဤယာယီဖက်ဒရေးရှင်း သည်ပြီးဆုံးခဲ့သည် {{date}} ဖြစ်သည်", + "ending-description": "ဤယာယီဖက်ဒရေးရှင်း သည် {{date}} ပြီးဆုံးပါမည်။ ကျန်ငွေများကို အုပ်ထိန်းသူများ၏ ဆုံးဖြတ်ချက်အရ စီမံခန့်ခွဲပါမည်", + "ending-in": "{{time}} တွင် အဆုံးသတ်သည်" + }, + "receive": { + "add-amount": "ပမာဏ ကိုထည့်ပါ", + "awaiting-deposit": "ငွေသွင်းခြင်း စောင့်ဆိုင်းနေသည်", + "awaiting-withdrawal-from": "{{domain}} မှ ထုတ်ယူမှုကို စောင့်ဆိုင်းနေသည်...", + "balance-not-spendable-offline": "အွန်လိုင်းပြန်မရောက်မချင်း ဤလက်ကျန်ငွေကို သုံးစွဲနိုင်မည်မဟုတ်ပါ", + "bitcoin-request": "ဘစ်ကွိုင် တောင်းဆိုမှု", + "camera-access-information": "အော့ဖ်လိုင်းငွေလက်ခံရရှိရန်၊ အော့ဖ်လိုင်းငွေပေးချေမှုကုဒ်ကို စကင်န်ဖတ်ရန် ဖယ်ဒီ ကို သင့်ကင်မရာသို့ ဝင်ရောက်ခွင့်ပြုရန် လိုအပ်ပါသည်။", + "copied-payment-code": "ငွေပေးချေမှု တောင်းဆိုခြင်း ကူးယူပြီးဖြစ်သည်", + "create-lightning-request": "လိုက်တ်နင် တောင်းဆိုခြင်း ပြုလုပ်ပါ", + "enable-onchain-deposits": "onchain အပ်ငွေများကို ဖွင့်ပါ", + "instructions": "သင်ရရှိလိုသောပမာဏကို ထည့်သွင်းပါ", + "maximum-invoice-amount": "အများဆုံး ငွေတောင်းခံလွှာပမာဏမှာ {{maxAmount}} SATS ဖြစ်သည်", + "onchain-notice": "ကွင်းဆက်အပ်ငွေများကို အတည်ပြုရန် ~10 နာရီ ကြာသည်။ လက်ငင်း ငွေပေးငွေယူများအတွက် လိုက်တ်နင် ကိုသုံးပါ။", + "pending-transaction": "ငွေလွှဲခြင်း ဆိုင်းငံ့နေသည်", + "receive-amount-unit": "{{amount}} {{unit}} လက်ခံရရှိသည်", + "receive-bitcoin": "ဘစ်ကွိုင် လက်ခံရရှိသည်", + "receive-bitcoin-offline": "ဘစ်ကွိုင်ကို အင်တာနက်မဲ့ လက်ခံရရှိသည်", + "request-bitcoin": "ဘစ်ကွိုင် တောင်းဆိုခြင်း", + "request-sats": "{{amount}} SATS တောင်းဆိုခြင်း", + "request-via-lightning": "လိုက်တ်နင် မှတောင်းဆိုခြင်း", + "withdraw-from-domain": "{{domain}} မှငွေထုတ်သည်", + "you-received": "သင် လက်ခံရရှိပြီးဖြစ်သည်", + "you-received-amount-unit": "သင် {{amount}} {{unit}} လက်ခံရရှိပြီးဖြစ်သည်" + }, + "recovery": { + "camera-access-information": "ပြန်လည်ရယူရာတွင် ကူညီရန်၊ လူမှုပြန်လည်ရယူရေးကုဒ်ကို စကင်န်ဖတ်ရန် သင့်ကင်မရာသို့ ဖယ်ဒီ ဝင်ရောက်ခွင့်ပေးရန် လိုအပ်ပါသည်", + "cancel-social-recovery": "လူမှုရေး ပြန်လည်ရယူခြင်းကို ပယ်ဖျက်ပါ", + "cancel-social-recovery-detail": "သင် လူမှုရေး ပြန်လည်ရယူခြင်း ကိုပယ်ဖျက်လိုပါသလား။", + "choose-method": "ပြန်လည်ရယူခြင်းနည်းလမ်းကိုရွေးချယ်ပါ", + "choose-method-instructions": "{{Federation}} သို့ သင်ပထမဆုံးဝင်ရောက်ခဲ့စဉ်က သင့်ပိုက်ဆံအိတ်ကို အရန်သိမ်းရန်အသုံးပြုသည့်နည်းလမ်းကို ရွေးချယ်ပါ။", + "choose-wallet-option": "ပိုက်ဆံအိတ် ရွေးချယ်မှု တစ်ခုလုပ်ပါ", + "complete-social-recovery": "လူမှုရေး ပြန်လည်ရယူခြင်းကို ပြီးမြောက်သည်", + "create-a-new-wallet": "ပိုက်ဆံအိတ် အသစ်ပြုလုပ်ပါ", + "create-a-new-wallet-instead": "ဤအစား ပိုက်ဆံအိတ်အသစ်ပြုလုပ်ပါ", + "create-new-wallet": "ဒေါင်းလုတ်လုပ်ခြင်း မအောင်မြင်ပါ", + "create-new-wallet-guidance": "သင်ပြန်လည်ရယူပြီး အခြားစက်သို့ လွှဲပြောင်းမပေးပါက ဤပိုက်ဆံအိတ်ကို ဤစက်တွင်သာ အသုံးပြုနိုင်မည်ဖြစ်သည်", + "download-failed": "ဒေါင်းလုဒ်လုပ်ရန် မအောင်မြင်ပါ", + "fresh-wallet": "ဤစက်ပေါ်ရှိ လတ်ဆတ်သော ပိုက်ဆံအိတ်", + "from-different-device": "မတူညီသောစက်မှ ဤတစ်ခုသို့", + "guardian-approval-instructions": "သင့်ငွေကို ပြန်လည်ရယူရန် သင်၏ ဖယ်ဒီ မိတ်ဆက်ဗီဒီယိုတွင် အုပ်ထိန်းသူများသည် သင်ဖြစ်ကြောင်း အတည်ပြုရန် လိုအပ်သည်", + "guardian-approval-step-1": "1. အုပ်ထိန်းသူများနှင့် အစည်းအဝေးများ စီစဉ်ပါ (အောက်ပါ Guardians ကိုကြည့်ပါ)", + "guardian-approval-step-2": "2. သင်၏ QR ကုဒ်ကို စကင်န်ဖတ်ရန် အုပ်ထိန်းသူ ကို တောင်းဆိုပါ။", + "guardian-approval-step-3": "3. သူတို့သည် သင့်ဗီဒီယိုကို ကြည့်ရှုပြီး သင့်အထောက်အထားကို စစ်ဆေးမည်ဖြစ်သည်။", + "guardian-approval-step-4": "4. သူတို့ အတည်ပြုပါက သင့်ပိုက်ဆံအိတ်သို့ သင့်ပိုက်ဆံကို ပြန်လည်ရရှိမည်ဖြစ်သည်။", + "guardian-approvals": "အုပ်ထိန်းသူ၏ အတည်ပြုချက်များ", + "guardian-qr-instructions": "လူမှုပြန်လည်ရယူခြင်းလုပ်ငန်းစဉ်စတင်ရန် အုပ်ထိန်းသူများသည် ဤ QR ကုဒ်ကို စကင်န်ဖတ်ရန် လိုအပ်ပါသည်။", + "guardians-remaining": "{{guardians}} ကျန်ပါသည်", + "invalid-qr-code": "မမှန်ကန်သော လူမှုပြန်လည်ရယူရေးကုဒ်", + "locate-social-recovery-file": "သင်၏အရန်ဖိုင်ကိုဖွင့်ပါ", + "locate-social-recovery-instructions-1": "သင်သည် ဤဖိုင်ကို သူငယ်ချင်းများနှင့် မျှဝေထားသည် သို့မဟုတ် မတူညီသောနေရာအနည်းငယ်တွင် သိမ်းဆည်းထားနိုင်သည်-", + "locate-social-recovery-instructions-3": "Fedi ဖိုင်အမည်သည် အောက်ပါအတိုင်းဖြစ်သည်-", + "locate-social-recovery-instructions-check-1": "ဒေါင်းလုတ်များ", + "locate-social-recovery-instructions-check-2": "ကလောက် သိုလှောင်မှု", + "locate-social-recovery-instructions-check-3": "စာများ", + "locate-social-recovery-instructions-check-4": "စသဖြင့်", + "locked-device-guidance-1": "ဤစက်ပေါ်ရှိ ပိုက်ဆံအိတ်ကို အခြားစက်သို့ လွှဲပြောင်းလိုက်ပါပြီ။", + "locked-device-guidance-2": "ဤ {{deviceName}} စက်ကို ယခုအခါ လော့ခ်ချထားသည်။ ❌", + "locked-device-guidance-3": "ဤစက်ပေါ်တွင် အခြားပိုက်ဆံအိတ်ကို ထည့်သွင်းလိုပါက၊ ကျေးဇူးပြု၍ Fedi ကိုဖျက်ပြီး ပြန်လည်ထည့်သွင်းပါ။ 👍", + "new-wallet": "ပိုက်ဆံအိတ်အသစ်", + "nothing-to-download": "အုပ်ထိန်းသူထံမှ ဒေါင်းလုတ်လုပ်စရာမရှိပါ", + "open-qr-code": "QR ကုဒ်ကိုဖွင့်ပါ", + "opening-backup-file-failed": "အရန်ဖိုင်များဖွင့်ခြင်းမအောင်မြင်ပါ", + "opening-backup-file-failed-instructions": "အရန်ဖိုင် {{fileName}} ကို ဖွင့်၍မရပါ။ ကျေးဇူးပြု၍ သင့်မျှဝေထားသော တည်နေရာများထဲမှ အခြားတစ်ခုမှ ပြန်လည်ရယူကြည့်ပါ။", + "paste-social-recovery-code": "လူမှုပြန်လည်ရယူရေးကုဒ်ကို ကူးထည့်ပါ", + "paste-social-recovery-code-instead": "ဤအစား လူမှုပြန်လည်ရယူရေးကုဒ်ကို ကူးထည့်ပါ", + "personal-recovery": "ကိုယ်ရေးကိုယ်တာ ပြန်လည်ထူထောင်ရေး", + "personal-recovery-instructions": "သင့်ပိုက်ဆံအိတ်ကိုအရန်သိမ်းသောအခါတွင် ဦးစွာသင်ရေးထားသော စကားလုံး ၁၂ လုံးကို ထည့်သွင်းပါ", + "personal-recovery-method": "ပြန်လည်ရယူရေး စကားလုံး ၁၂ လုံးကို ရေးမှတ်ထားပါက သင့်ပိုက်ဆံအိတ်ကို ပြန်လည်ရယူရန် ၎င်းတို့ကို ထပ်မံထည့်သွင်းနိုင်သည်", + "recover-a-wallet": "ပိုက်ဆံအိတ် တစ်ခု ပြန်လည်ရယူပါ", + "recover-wallet": "ပိုက်ဆံအိတ်ကိုပြန်လည်ရယူပါ", + "recover-wallet-with-balance": "လက်ကျန်ငွေရှိနေသော ပိုက်ဆံအိတ်အတွက် ပြန်လည်ရယူခြင်းမပြုလုပ်နိုင်ပါ။ ပထမဦးစွာ လက်ကျန်ငွေများကို ထုတ်ပါ။", + "recovering-your-wallet": "သင်၏ ပိုက်ဆံအိတ်ကို ပြန်လည်ရယူနေပါသည်။ ပြန်လည်၍စစ်ဆေးကြည့်ပါ။", + "recovery-assist": "ပြန်လည်ရယူခြင်းအတွက် ကူညီမှု", + "recovery-assist-confirm-check-1": "သင်နှင့်အတူအဖွဲ့ဝင်သည် သက်သောင့်သက်သာရှိပြီး တည်ငြိမ်နေသည်", + "recovery-assist-confirm-check-2": "လူတစ်ဦးချင်းစီ၏ အနီးပတ်ဝန်းကျင်သည် လုံခြုံသည်", + "recovery-assist-description": "သင့်ဖက်ဒရေးရှင်း အဖွဲ့ဝင်တစ်ဦးသည် သူတို့၏ ပိုက်ဆံအိတ်ကို ပြန်လည်ရယူရာတွင် သင့်အကူအညီကို တောင်းခံနေသည်။", + "recovery-assist-instructions-1": "1. အဖွဲ့ဝင်သည် တည်ငြိမ်ပြီး ဘေးကင်းကြောင်း စစ်ဆေးပါ။", + "recovery-assist-instructions-2": "2. အဖွဲ့ဝင်များ QR ကုဒ်ကို စကင်န်ဖတ်ပါ။", + "recovery-assist-instructions-3": "3. သူတို့၏ ဖယ်ဒီ အရန်ဗီဒီယိုကို ကြည့်ပါ။", + "recovery-assist-instructions-4": "4. သူတို့သည် အရန်ဗီဒီယိုတွင် တူညီကြောင်း အတည်ပြုပါ။", + "recovery-assist-instructions-5": "5. အဖွဲ့ဝင်များ ပြန်လည်ရယူရေး တောင်းဆိုချက်ကို အတည်ပြုသည်သို့မဟုတ် ငြင်းပယ်သည်။", + "recovery-assist-process": "ပြန်လည်ထူထောင်ရေး အထောက်အကူပြုလုပ်ငန်းစဉ်", + "recovery-assist-thank-you": "အသင်းသားတစ်ဦးအတွက် ငွေပြန်ရှာပေးသည့်အတွက် ကျေးဇူးတင်ပါသည်", + "recovery-confirm-identity-instructions-1": "အဖွဲ့ဝင်အထောက်အထားကို အတည်ပြုရန် ဗီဒီယိုကို ကြည့်ပါ", + "recovery-confirm-identity-instructions-2": "ဤဗီဒီယိုတွင် ပြသထားသူသည် ယခု လူမှုရေး ပြန်လည်ရယူခြင်း ကိုကြိုးစားနေသူဖြစ်ပါသလား", + "recovery-confirm-identity-no": "မဟုတ်ပါ၊ ဗီဒီယိုရှိပုဂ္ဂိုလ်သည် အဖွဲ့ဝင်နှင့် တူညီသောပုဂ္ဂိုလ်မဟုတ်ပါ", + "recovery-confirm-identity-yes": "ဟုတ်တယ်၊ ဗီဒီယိုထဲကလူဟာ အဖွဲ့ဝင်နဲ့ အတူတူပါပဲ", + "recovery-in-progress-balance": "ပြန်လည်ရယူခြင်း ပြုလုပ်နေပါသည်။ မကြာမီ လက်ကျန်ငွေများပြန်လည်ရရှိနိုင်ပါမည်။", + "recovery-in-progress-chat-payments": "ပြန်လည်ရယူခြင်း ပြုလုပ်နေပါသည်။ မကြာမီ စကားပြောခန်း ငွေပေးချေမှု ပြုလုပ်နိုင်ပါမည်။", + "recovery-in-progress-payments": "ပြန်လည်ရယူခြင်း ပြုလုပ်နေပါသည်။ မကြာမီ ငွေပေးချေမှုများ ပြုလုပ်နိုင်ပါမည်။", + "search-files": "ဖိုင်များကိုရှာပါ", + "select-a-device": "Device ရွေးချယ်ပါ", + "select-a-device-guidance": "ရှိပြီးသား ပိုက်ဆံအိတ်ကို လွှဲပြောင်းရန် စက်ကို ရွေးချယ်ပါ", + "social-recovery": "လူမှုရေး ပြန်လည်ရယူခြင်း", + "social-recovery-instructions": "သင့်အရန်ဗီဒီယိုကို မှတ်တမ်းတင်ထားချိန်တွင် ဖန်တီးထားသည့် သင်၏လူမှုရေးပြန်လည်ရယူရေး Fedi ဖိုင်ကိုဖွင့်ပါ။ ဖိုင်ကို သိမ်းဆည်းပြီး သူငယ်ချင်းများနှင့် မျှဝေရန် သင့်အား ကျွန်ုပ်တို့ တောင်းဆိုပါသည်", + "social-recovery-method": "ဗီဒီယိုရိုက်ထားလျှင် သင်၏အထောက်အထားကို အုပ်ထိန်းသူများနှင့် တွေ့ဆုံအတည်ပြုရမည်", + "social-recovery-steps": "လူမှုရေးပြန်လည်ရယူခြင်းအဆင့်များ", + "social-recovery-unsuccessful": "လူမှုရေး ပြန်လည်ရယူခြင်း မအောင်မြင်ပါ", + "social-recovery-unsuccessful-instructions": "အုပ်ထိန်းသူများထံ ပြန်သွားပြီး သင်၏အထောက်အထားကို သက်သေပြပါ", + "start-personal-recovery": "ကိုယ်ရေးကိုယ်တာ ပြန်လည်ရယူခြင်းကို စတင်ပါ", + "start-social-recovery": "လူမှုရေးပြန်လည်ထူထောင်ရေးစတင်ပါ", + "successfully-opened-fedi-file": "သင်၏ ဖယ်ဒီ ဖိုင်ကို အောင်မြင်စွာ ဖွင့်ပြီးပါပြီ", + "transfer-existing-wallet": "ရှိပြီးသား ပိုက်ဆံအိတ်ကို လွှဲပြောင်းပါ", + "transfer-existing-wallet-guidance-1": "သင့်လက်ရှိပိုက်ဆံအိတ်ကို ဤစက်သို့ ယူဆောင်လာပြီး အခြားစက်တွင် ပိုက်ဆံအိတ်ကို လော့ခ်ချမည်ဖြစ်သည်", + "transfer-existing-wallet-guidance-2": "လွှဲပြောင်းရန် သင်သည် စက်ဟောင်းကို မလိုအပ်ပါ", + "try-social-recovery-again": "လူမှုရေးပြန်လည်ရယူရန် ထပ်မံကြိုးစားပါ", + "wallet-transfer": "ပိုက်ဆံအိတ်လွှဲပြောင်း", + "wallet-was-transferred": "ပိုက်ဆံအိတ်ကို လွှဲပြောင်းခဲ့သည်", + "you-completed-personal-recovery": "သင်သည် ကိုယ်ရေးကိုယ်တာ ပြန်လည်ရယူခြင်းကို ပြီးမြောက်ခဲ့သည်", + "you-completed-social-recovery": "သင်သည် လူမှုပြန်လည်ရယူရေး ပြီးမြောက်ခဲ့သည်" + }, + "send": { + "camera-access-information": "ငွေပို့ရန်၊ ငွေပေးချေမှုတောင်းဆိုချက်ကို စကင်န်ဖတ်ရန် သင့်ကင်မရာသို့ ဖယ်ဒီ ဝင်ရောက်ခွင့်ကို ခွင့်ပြုရပါမည်", + "confirm-ecash-send": "အီးကက်ရှ် ပေးပို့မှုကို အတည်ပြုပါ", + "confirm-send": "ပေးပို့ရန် အတည်ပြုပါ", + "copied-offline-payment": "အော့ဖ်လိုင်းငွေပေးချေမှုကို ကူးယူထားသည်", + "enter-payment-request": "ငွေပေးချေမှုတောင်းဆိုချက်ကို ထည့်သွင်းပါ", + "hold-to-confirm-send": "ပေးပို့ခြင်းကို အတည်ပြုရန် ဖိထားပါ", + "i-have-sent-payment": "ငွေပေးချေပြီးပါပြီ", + "offline-send-warning": "နောက်တစ်ဆင့်တွင်၊ QR ကိုတင်ပြသောအခါ၊ Sats များကို သင့်ပိုက်ဆံအိတ်မှ ဖယ်ရှားပါမည်", + "paste-payment-request": "ငွေပေးချေမှု တောင်းဆိုခြင်းအား ကူးယူပြီးဖြစ်သည်", + "paste-payment-request-instead": "ဤအစား ငွေပေးချေမှု တောင်းဆိုခြင်းအား ကူးယူပါ", + "refund-in-block": "ပြန်အမ်းငွေ {{block}} ဖြစ်နေသည်", + "scan-qr-code": "လိုက်တ်နင် QR ကုဒ်အတွက် စကန်ဖတ်ပါ", + "send-amount-unit": "{{amount}} {{unit}} ကိုပို့ပါ", + "send-bitcoin": "ဘစ်ကွိုင် ပေးပို့သည်", + "send-bitcoin-offline": "ဘစ်ကွိုင်ကို အင်တာနက်မရှိပဲ ပေးပို့သည်", + "send-from": "မှ ပေးပို့သည်", + "send-offline": "အင်တာနက်မရှိပဲ ပေးပို့သည်", + "send-sats": "{{amount}} SATS ပေးပို့သည်", + "send-to": "သို့ ပေးပို့သည်", + "send-to-offline-user": "အင်တာနက်အသုံးမပြုသူ သို့ပေးပို့သည်", + "you-are-sending": "ပေးပို့နေသည်", + "you-are-sending-amount-unit": "သင်သည် {{amount}} {{unit}} ပေးပို့နေသည်", + "you-sent": "သင်ပေးပို့ပြီးဖြစ်သည်", + "you-sent-amount-unit": "သင်သည် {{amount}} {{unit}} ကိုပေးပို့ပြီးဖြစ်သည်" + }, + "settings": { + "currency-names": { + "ars": "အာဂျင်တီးနီးယန်း ပီဆို", + "aud": "ဩစတေးလျန်း ဒေါ်လာ", + "bdt": "ဘန်းဂလားဒေ့ရှ် တာခါ", + "bif": "ဘူရန်းဒီးယန်း ဖရန့််ခ်", + "brl": "ဘရာဇီးရန်း ရီးရဲ", + "bwp": "ဘော့စ်ဝါနာ ပူလာ", + "cdf": "ကွန်ဂိုလိစ့် ဖရန့်ခ်", + "cfa": "စန်ထရယ် အာဖရိကန် ဖရန့်ခ်", + "clp": "ချီလီယန် ပီဆို", + "cop": "ကိုလန်ဘီယန် ပီဆို", + "cup": "ကူဘန် ပီဆို", + "czk": "ရှဇက် ခိုရူနာ", + "djf": "ဂျဘူးတီးယန်း ဖရန့်ခ်", + "ern": "အာရေးထရေးရန်း နတ်ဖာ", + "etb": "အီသီယိုးပီးယန်း ဘ", + "eur": "ယူရို", + "ghs": "ဂါနားယန်း စီဒီ", + "gtq": "ဂွါတာမာလွန် ကွက်ဇယ်", + "hkd": "ဟောင်ကောင် ဒေါ်လာ", + "hnl": "ဟုမ်ဒူရမ် လမ်ပီရာ", + "idr": "အင်ဒိုနီးရှန်း ရူပီး", + "inr": "အင်ဒီးယန်း ရူပီး", + "kes": "ကန်ညန် ရှီလင်", + "lbp": "လဘန်နီ့စ် ပေါင်", + "mmk": "မြန်မာ ကျပ်", + "mwk": "မလေးဝီးယန်း ကွာချာ", + "mxn": "မက်ဆီကန် ပီဆို", + "myr": "မလေးရှား ရင်းဂစ်တ်", + "nad": "နမ်မီဘီးယန်း ဒေါ်လာ", + "ngn": "နိုင်ဂျီးရီးယန်း နိုင်ရာ", + "pen": "ပီရူး နူဗို ဆိုးလ်", + "php": "ဖီလင်းပိုင် ပီဆို", + "rwf": "ရဝမ်ဒါဖရန့်ခ်", + "sdg": "ဆူဒန်ပေါင်", + "sos": "ဆိုမာလီ ရှီလင်", + "ssp": "တောင်ဆူဒန်ပေါင်", + "thb": "ထိုင်း ဘတ်", + "ugx": "ယူဂန်ဒါ ရှီလင်များ", + "usd": "အမေရိကန် ဒေါ်လာ", + "uyu": "ဥရုဂေးရန်း ပီဆို", + "ves": "ဘိုလီဗာ့စ်", + "vnd": "ဗီယက်နမ် ဒုမ်", + "xaf": "ကမ်မရွမ်", + "zar": "တောင်အာဖရိက ရန်", + "zmw": "ဇမ်ဘီးယန်း ကွာချာ" + } + }, + "stabilitypool": { + "amount-may-vary": "ပမာဏ ကွဲပြားနိုင်သည်", + "amount-may-vary-during-withdraw": "ငွေထုတ်ခြင်းအတွင်း ပမာဏ ကွဲပြားနိုင်သည်", + "amount-pending": "{{amount}} ဆိုင်းငံ့လျက်ရှိသည်", + "available-to-deposit": "ငွေသွင်းနိုင်သည်", + "available-to-withdraw": "ငွေထုတ်နိုင်သည်", + "bitcoin-amount": "ဘစ်ကွိုင် ပမာဏ", + "bitcoin-balance": "ဘစ်ကွိုင် လက်ကျန်ငွေ", + "confirm-deposit": "ငွေသွင်းခြင်း အတည်ပြုပါ", + "confirm-withdrawal": "ငွေထုတ်ခြင်း အတည်ပြုပါ", + "currency-balance": "{{currency}} လက်ကျန်ငွေ", + "current-value": "လက်ရှိ တန်ဖိုး", + "deposit-amount": "ငွေသွင်းမည့် ပမာဏ", + "deposit-from": "မှ ငွေသွင်းမည်", + "deposit-intiated": "ငွေသွင်းခြင်း စတင်ပြီးဖြစ်သည်", + "deposit-pending": "{{amount}} ငွေသွင်းခြင်း ဆိုင်းငံ့နေသည်", + "deposit-time": "ငွေသွင်းသည့်အချိန်", + "deposit-to": "သို့ ငွေသွင်းသည်", + "deposit-to-balance": "{{currency}} လက်ကျန်သို့ ငွေသွင်းသည်", + "deposit-value": "ငွေသွင်းမည့် တန်ဖိုး", + "deposits-disabled-by-federation": "Stability Pool ငွေသွင်းခြင်းများကို ဖယ်ဒရေးရှင်းမှ ပိတ်ထားသည်။", + "details-and-fee": "အသေးစိတ်အချက်အလက်နှင့် ကျသင့်ငွေ", + "enter-deposit-amount": "ငွေသွင်းမည့်ပမာဏကိုဖြည့်ပါ", + "enter-withdrawal-amount": "ငွေထုတ်မည့်ပမာဏကိုဖြည့်ပါ", + "fees-paid": "ကျသင့်ငွေ ပေးပြီးဖြစ်သည်", + "max-stable-balance-amount": "တည်ငြိမ်သောလက်ကျန်ငွေ အများဆုံးပမာဏသို့ရောက်ရှိသွားပါပြီ", + "minutes": "{{minutes}} မိနစ်", + "more-than-an-hour": "၁ + နာရီ", + "no-bitcoin-notice": "သင်သည် {{currency}} လက်ကျန်ငွေ သို့ ဘစ်ကွိုင် ငွေသွင်းခဲ့ပါသည်", + "one-minute": "တစ်မိနစ်", + "one-second": "တစ်စက္ကန့်", + "pending-withdrawal-blocking": "နောက်ထပ် ငွေသွင်းခြင်း ငွေထုတ်ခြင်း များမလုပ်ခင် ဆိုင်းငံ့ထားသော ငွေထုတ်ခြင်း လုပ်ဆောင်ချက် ကိုလဏစောင့်ပါ", + "seconds": "{{seconds}} စက္ကန့်", + "will-be-deposited": "{{amount}} ကို {{expectedWait}} သို့ထည့်သွင်းခဲ့သည်", + "will-be-withdrawn": "{{amount}} ကို {{expectedWait}} မှထုတ်ယူခဲ့သည်", + "withdraw-to": "သို့ ငွေထုတ်မည်", + "withdrawal-amount": "ငွေထုတ်မည့်ပမာဏ", + "withdrawal-from": "မှ ငွေထုတ်မည်", + "withdrawal-from-balance": "{{currency}} လက်ကျန်ငွေမှ ငွေထုတ်မည်", + "withdrawal-intiated": "ငွေထုတ်ခြင်း စတင်ပြီးဖြစ်သည်", + "withdrawal-pending": "{{amount}} ငွေထုတ်ခြင်း ဆိုင်းငံ့နေသည်", + "withdrawal-time": "ငွေထုတ်သည့် အချိန်", + "withdrawal-value": "ငွေထုတ်သည့် တန်ဖိုး", + "you-deposited": "သင် ငွေသွင်းလိုက်ပါသည်", + "you-withdrew": "သင် ငွေထုတ်လိုက်ပါသည်" + }, + "wallet": { + "network-notice": "ဤဖယ်ဒရေးရှင်း သည် {{network}} SATS ကိုအသုံးပြုပါသည်", + "show-fiat-txn-amounts": "Fiat ငွေပေးချေခြင်း ပမာဏ ကိုပြပါ", + "show-fiat-txn-amounts-info": "ငွေပေးချေမှု ပမာဏကို ရွေးချယ်ထားသော Fiat ငွေကြေး ဖြင့်ပြသထားမည်" + } + } +} diff --git a/ui/common/localization/sw/common.json b/ui/common/localization/sw/common.json index bc21953..a19d93a 100644 --- a/ui/common/localization/sw/common.json +++ b/ui/common/localization/sw/common.json @@ -1,17 +1,17 @@ { "words": { - "accept": "Itika", + "accept": "Kubali", "actions": "Matendo", - "address": "Nyumbani", + "address": "Anwani", "admin": "Admin", - "amount": "montant ya makuta", - "approved": "Imekubaliwa", - "authorize": "Ruhusu", + "amount": "Kiasi cha pesa", + "approved": "imeidhinishwa", + "authorize": "Mamlaka", "backup": "Backup", - "balance": "Solde", + "balance": "Baki", "bitcoin": "Bitcoin", - "cancel": "Vuta", - "canceled": "Imevutwa", + "cancel": "Futa", + "canceled": "imefutwa", "chat": "Mazungumzo", "community": "Jumuiya", "complete": "Kamilisha", @@ -19,246 +19,246 @@ "confirmed": "Imehakikishwa", "continue": "Endelea", "copy": "Nakili", - "currency": "Makuta", + "currency": "Fedha", "deposit": "Weka", "details": "Maelezo", "done": "Imefanyika", "email": "Barua pepe", - "enjoy": "Furahiya", + "enjoy": "Furahia", "error": "Kosa", "expired": "Imepitwa na wakati", "failed": "Imeshindwa", "fedimint": "Fedimint", "fedimods": "FediMods", - "fee": "Malipo", - "fees": "Garama", + "fee": "Ada", + "fees": "Ada", "from": "Kuanzia", "general": "Jumla", "group": "Kikundi", "history": "Historia", "home": "Mwanzo", "icon": "Alama", - "important": "Ya muhimu", + "important": "muhimu", "invite": "Alika", "invited": "Umealikwa", "join": "Jiunge", "joined": "Umejiunga", - "language": "Luga", + "language": "lugha", "leave": "Toka", "lightning": "Lightning", "member": "Mwanamemba", "members": "Wanamemba", "memo": "Maelezo", - "message": "Ujumbe mfupi", - "messages": "Jumbe fupi", - "moderator": "Musimamizi", - "next": "Yakufuata", + "message": "Ujumbe", + "messages": "Ujumbe", + "moderator": "Msimamizi", + "next": "ijayo", "no": "Apana", "nostr": "Nostr", "notes": "Maelezo", "okay": "Sawa", "onchain": "On-chain", "one": "Moja", - "optional": "Yenye inachaguliwa", + "optional": "hiari", "paid": "Imelipwa", "pay": "Lipa", "payments": "Malipo", - "pending": "Inangoya", + "pending": "inasubri", "people": "Watu", - "receive": "Pokeya", + "receive": "Pokea", "received": "Imepokelewa", "receiving": "Kupokea", - "refund": "Rudisha makuta", - "reject": "Katala", + "refund": "Rejesha", + "reject": "Kataa", "rejected": "Imekataliwa", "remaining": "Imebaki", "request": "Ombi", "required": "Inahitajika", "retry": "Jaribu tena", "sats": "sats", - "save": "Backup", + "save": "Hifadhi", "scan": "Fanya scanner", "seen": "Imeonekana", "send": "Tuma", "sent": "Imetumwa", - "settings": "Reglages", - "share": "Gabula", + "settings": "Mipangilio", + "share": "Shiriki", "skip": "Ruka", "status": "Hali", - "stay": "Bakiya", + "stay": "Baki", "submit": "Wasilisha", "time": "Saa", "title": "Kichwa", - "to": "Paka ", - "transactions": "Matumizi ya makuta", + "to": "Kwa", + "transactions": "Shughuli", "two": "Mbili", - "unknown": "Haijulikane", - "unsupported": "Haikubalike", + "unknown": "Haijulikani", + "unsupported": "Haikubaliki", "upload": "Weka", "url": "url", "URL": "URL", "username": "Jina ya mtumizi", - "wallet": "Mufuko", - "withdraw": "Towa", - "withdrawal": "Kutowa", + "wallet": "Pochi", + "withdraw": "Ondoa", + "withdrawal": "Uondoaji", "yes": "Ndio", - "you": "Weye" + "you": "Wewe" }, "phrases": { "add-note": "Ongeza maelezo", "allow-camera-access": "Ruhusu Kamera", - "app-settings-security": "Reglages ya app na usalama", + "app-settings-security": "Mipangilio ya app na usalama", "app-version": "Fedi Version: {{version}}", - "back-to-app": "Rudia ku app", - "backup-your-wallet": "Buckup mufuko yako", - "bitcoin-address": "Adresse ya Bitcoin ", - "bitcoin-address-created": "Adresse ya Bitcoin imeundwa", - "bitcoin-equivalent": "montant sawa ya Bitcoin", - "camera-settings": "Reglages ya Kamera", - "changes-may-not-be-saved": "Mabadiliko ulifanya inaweza kosa ku Back upwa", - "click-for-more-details": "Finya kwa maelozo zaidi", + "back-to-app": "Rudi kwa app", + "backup-your-wallet": "Backup pochi lako", + "bitcoin-address": "Anwani ya Bitcoin", + "bitcoin-address-created": "Anwani ya Bitcoin imeundwa", + "bitcoin-equivalent": "sawa na Bitcoin", + "camera-settings": "Mipangilio ya Kamera", + "changes-may-not-be-saved": "Mabadiliko uliyofanya yanaweza kosa kuhifadhiwa", + "click-for-more-details": "Finya kwa maelezo zaidi", "connect-to-federation": "Jiunge kwa Jumuiya ya kujaribu", - "copied-bitcoin-address": "Adresse ya Biticoin imenakiliwa", + "copied-bitcoin-address": "Anwani ya Biticoin imenakiliwa", "copied-ecash-token": "token ya ecash imenakiliwa", "copied-lightning-request": "Ombi ya lightning imenakiliwa", - "copied-member-code": "Code ya mwanamemba wa Fedi imenakiliwa", + "copied-member-code": "Imenakili msimbo wa mwanamemba wa Fedi", "copied-to-clipboard": "Imenakiliwa kwenye ubao", - "copied-transaction-id": "Namba ya matumizi ya makuta imenakiliwa", - "display-currency": "Onesha makuta", + "copied-transaction-id": "Kitambulisho cha muamala kimenakiliwa", + "display-currency": "Onyesha fedha", "edit-note": "Badilisha elezo", - "email-address": "Adresse ya email", + "email-address": "Anwani ya barua pepe", "expires-in": "Inaisha baada ya", - "failed-to-decode-invoice": "Imeshindwa kutambua facture", - "federation-fee": "Malipo ya Jumuiya", - "fedi-fee": "Malipo ya Fedi", - "fee-details": "Maelezo ya malipo", - "generate-invoice": "Tengeneza facture", - "go-back": "Rudiya nyuma", - "hide-details": "Fichia maelezo", + "failed-to-decode-invoice": "Imeshindwa kusimbua ankara", + "federation-fee": "Ada ya jumuiya", + "fedi-fee": "Ada ya Fedi", + "fee-details": "Maelezo ya ada", + "generate-invoice": "Tengeneza ankara", + "go-back": "Rudi nyuma", + "hide-details": "Ficha maelezo", "hold-to-confirm": "Shikilia ili uhakikishe", "i-understand": "Na elewa", - "invalid-federation-code": "Code ya Jumuiya isiofaa", - "join-another-federation": "Jiunge na Jumuiya engine", + "invalid-federation-code": "Msimbo batili wa jumuiya", + "join-another-federation": "Jiunge na Jumuiya ingine", "last-seen": "Mara ya mwisho kuonekana", - "lightning-address": "Adresse ya lightning", + "lightning-address": "Anwani ya lightning", "lightning-network": "Mtandao wa lightning", "lightning-request": "Ombi ya lightning", - "network-fee": "Malipo ya mtandao", - "new-member": "Mwanamemba wa mupya", - "no-transactions": "Hakuna matumizi ya makuta ", - "onchain-address": "Adresse ya On-chain", - "open-in-browser": "Fungula na navigateur", + "network-fee": "Ada ya mtandao", + "new-member": "Mwanamemba mpya", + "no-transactions": "Hakuna shughuli", + "onchain-address": "Anwani ya On-chain", + "open-in-browser": "Fungua katika kivinjari", "paste-from-clipboard": "Bandika kutoka kwa ubao", - "please-confirm": "Tafazali hakikisha", - "receive-pending": "Inangoya kupokea", - "received-bitcoin": "Umepokea Bitcoin ", - "refund-pending": "Kurudisha makuta inasubiri", + "please-confirm": "Tafadhali hakikisha", + "receive-pending": "Inasubiri kupokea", + "received-bitcoin": "Umepokea Bitcoin", + "refund-pending": "Urejeshaji wa pesa unasubiri", "reload-app": "Anzisha tena app", - "return-to-home": "Rudiya ku mwanzo", - "save-changes": "Backup mabadiliko", + "return-to-home": "Rudi mwanzo", + "save-changes": "Hifadhi mabadiliko", "sent-bitcoin": "Bitcoin imetumwa", "start-over": "Anza upya", "terms-and-conditions": "Sheria na Masharti", - "transaction-id": "Namba ya matumizi ya makuta", - "transaction-received": "Matumizi ya makuta imepokelewa", - "view-public-federations": "Angaliya miungano ya watu wote", - "yearly-fee": "Malipo ya kila mwaka", - "you-are-offline": "Kwa sasa uko inje ya mtandao offline" + "transaction-id": "Kitambulisho cha muamala", + "transaction-received": "Muamala umepokelewa", + "view-public-federations": "Tazama jumuiya za umma", + "yearly-fee": "Ada ya kila mwaka", + "you-are-offline": "Kwa sasa uko nje ya mtandao" }, "errors": { - "bad-connection": "Mtandao yako ni mbaya. ", - "browser-feature-not-supported": "Navigateur yako haitumiye hiyi fonction", - "camera-unavailable": "Imeshindwa kutumika na kamera yako", - "chat-connection-unhealthy": "Mtandao wa chat ni mbaya ao iko offline. ", - "chat-list-render-error": "Tumekutana na shida katika chat yako", - "chat-member-not-found": "Hatukuweza kupata mtumiaji huyu", - "chat-message-render-error": "Tumekutana na shida katika hiyi ujumbe ", - "chat-payment-failed": "Hatukuweza ku actualize malipo ya mazungumzo hayo", - "chat-unavailable": "Hiyi Jumuiya haina serveur ya chat", + "bad-connection": "Muunganisho wako wa mtandao unaweza kutokuwa thabiti. Tafadhali jaribu tena baadaye au unganisha kwenye mtandao mwingine.", + "browser-feature-not-supported": "Kivinjari chako hakitumii kipengele hiki.", + "camera-unavailable": "Imeshindwa kufikia kamera yako.", + "chat-connection-unhealthy": "Muunganisho wa gumzo si dhabiti au uko nje ya mtandao. Tafadhali jaribu tena baadaye au uwashe programu upya.", + "chat-list-render-error": "imekumbana na hitilafu wakati wa kutoa gumzo zako", + "chat-member-not-found": "Haikuweza kupata mtumiaji huyu", + "chat-message-render-error": "Imekumbana na hitilafu katika kutoa ujumbe huu", + "chat-payment-failed": "Haikuweza kusasisha malipo hayo ya gumzo", + "chat-unavailable": "Jumuiya hii haina seva ya gumzo.", "failed-to-fetch-gateways": "Imeshindwa kupata gateways", - "failed-to-fetch-guardian-approval": "Imeshindwa kupata ruhusa ya muhongozi", - "failed-to-fetch-transactions": "Imeshindwa kupata matumizi ya makuta", - "failed-to-generate-invoice": "Imeshindwa kutengeneza facture", + "failed-to-fetch-guardian-approval": "Imeshindwa kupata ruhusa ya mwongozi", + "failed-to-fetch-transactions": "Imeshindwa kuleta miamala", + "failed-to-generate-invoice": "Imeshindwa kutengeneza ankara", "failed-to-join-federation": "Imeshindwa kujiunga na Jumuiya", "failed-to-leave-federation": "Imeshindwa kutoka kwenye Jumuiya", "failed-to-load-tos": "Imeshindwa kupakia masharti ya utumiaji", - "failed-to-pay-invoice": "Imeshindwa kulipa facture", + "failed-to-pay-invoice": "Imeshindwa kulipa ankara", "failed-to-switch-gateways": "Imeshindwa kubadilisha gateways", - "get-nostr-pubkey-failed": "Imeshindwa kupata fungulo ya nostr pub", - "history-render-error": "Tumekutaana na shida kutuma iyi kitu.", - "insufficient-balance": "Makuta ni kidogo. Uko na {{balance}} pekeyake katika mufuko yako", - "invalid-amount-max": "montant ya yulu sana unaweza {{verb}} ni {{amount}}", - "invalid-amount-min": "montant ya chini sana unaweza {{verb}} ni {{amount}}", - "invalid-ecash-token": "token ya ecash ayi kubalike", - "invalid-federation-code": "Code ya Jumuiya ayi kubalike", - "invalid-group-code": "Hiyi code ya kikundi ayi kubalike", - "invalid-username": "Lazima ikuwe 21 caractere ao chini na isikuwe na majuscule", + "get-nostr-pubkey-failed": "Imeshindwa kupata funguo ya nostr pub", + "history-render-error": "Imekumbana na hitilafu katika kutoa kipengee hiki", + "insufficient-balance": "Usawa usiotosha. Una {{balance}} pekee kwenye pochi yako", + "invalid-amount-max": "Kiwango cha juu unachoweza {{verb}} ni {{amount}}", + "invalid-amount-min": "Kiwango cha chini unachoweza {{verb}} ni {{amount}}", + "invalid-ecash-token": "Tokeni batili ya ecash", + "invalid-federation-code": "Tokeni batili ya jumuiya", + "invalid-group-code": "Huu si msimbo halali wa kikundi", + "invalid-username": "Lazima iwe na herufi 21 au chini na haiwezi kujumuisha herufi kubwa", "no-lightning-gateways": "Hakuna gateways ya lightning iliyo patikana kwa mtumiaji huyu", "onchain-deposits-disabled": "Huduma za kuweka za Onchain zimezimwa kwa Jumuiya huu.", - "only-group-owners-can-change-name": "Waanzilishi wa kikundi pekeyao njo wanaweza kubadilisha jina ya kikundi", + "only-group-owners-can-change-name": "Waanzilishi wa kikundi pekeyao ndio wanaweza kubadilisha jina ya kikundi", "please-force-quit-the-app": "Shida imetokea, tafazali funga app na ujaribu tena.", "receive-ecash-failed": "Imeshindwa kupokea ecash", - "receives-have-been-disabled": "kupokea makuta imezimishwa kwa Jumuiya huu.", + "receives-have-been-disabled": "Kupokea kumezimwa kwa jumuiya hii", "recovery-failed": "Inashindikana ku rudisha, tafazali jaribu tena", - "sign-nostr-event-failed": "Inashindikana kuingia ndani ya nostr", - "unknown-error": "Shida isiyo julikana imejitokeza", - "username-already-exists": "Hiyi jina ya mtumiaji isha tumiwa. Rejesha mfuko yako ili kurejesha jina yako ya mtumiaji.", - "webln-canceled": "Ombi limekataliwa", - "webln-method-not-supported": "{{method}} hayi kubalike", - "webln-payment-rejected": "Malipo imekataliwa", - "webln-payment-request-rejected": "Ombi ya malipo imekataliwa", - "you-have-already-joined": "Umejiunga tayari na hiyi Jumuiya" + "sign-nostr-event-failed": "Imeshindwa kutia saini tukio la nostr", + "unknown-error": "Hitilafu isiyojulikana imetokea", + "username-already-exists": "Jina hili la mtumiaji tayari lipo. Rejesha pochi lako ili kurejesha jina lako la mtumiaji.", + "webln-canceled": "Ombi limeghairiwa", + "webln-method-not-supported": "{{njia}} haitumiki", + "webln-payment-rejected": "Malipo yamekataliwa", + "webln-payment-request-rejected": "Ombi la malipo limekataliwa", + "you-have-already-joined": "Umejiunga tayari na jumuiya hii" }, "feature": { "backup": { - "backup-social-recovery-file": "Backup Social Recovery yako kama mtumiaji", + "backup-social-recovery-file": "Backup faili yako ya Social Recovery", "backup-to-google-drive": "Backup kwenye Google Drive", - "backup-wallet": "Backup mufuko yako", - "backups-made": "Backup yako inamalizika", + "backup-wallet": "Backup pochi lako", + "backups-made": "Backups imemalizika", "camera-access-information": "Kutengeneza Social Backup, utahitaji kuruhusu Fedi kufikia kamera yako ili kurekodi video ya akikisho", - "choose-method": "Chaguwa njiya ya backup", - "choose-method-instructions": "Ku backup backup ya mufuko wako itakusaidia kupata makuta zako hata kama utapoteza uwezo wa kuingia kwenye mtandao wa Fedi wakati wowote", + "choose-method": "Chagua njia ya backup", + "choose-method-instructions": "Ku backup pochi lako itakusaidia kupata pesa zako hata ukipoteza ufikiaji wa mtandao wa Fedi wakati wowote", "cloud-backup": "Backup ya wingu", - "complete-social-backup": "Social Backup kamili", - "confirm-backup-video": "Hakikisha Backup ya video", - "creating-recovery-file": "Inaunda faili ya urejesho iliyofichwa...", - "export-transactions-to-csv": "Hamisha matumizi ya makuta kwa CSV", - "file-backup": "Backup backup ya faili", - "hold-record-button": "Shikilia bouton ya kurekodi na useme:", + "complete-social-backup": "Kamilisha Social Backup", + "confirm-backup-video": "Thibitisha backup video", + "creating-recovery-file": "Inabadilisha video yako kuwa faili ya chelezo ya Fedi iliyosimbwa kwa njia fiche... Ni ya faragha 100% hadi ujaribu kurejesha pochi yako", + "export-transactions-to-csv": "Hamisha miamala kwa CSV", + "file-backup": "Backup ya faili", + "hold-record-button": "Shikilia kitufe cha kurekodi na useme:", "personal-backup": "Backup ya kibinafsi", - "please-review-backup-video": "Tafazali pitia tena Backup ya video ", + "please-review-backup-video": "Tafazali pitia tena Backup ya video", "press-record-button": "Finya bouton ya kurekodi na useme:", "record-again": "Rekodi tena", - "record-error": "Tumekutana na shida wakati wa kutumia kamera yako", + "record-error": "Imekumbana na hitilafu kwa kutumia kamera yako", "record-video": "Rekodi video", - "recovery-words": "Maneno ya kurudisha", + "recovery-words": "Maneno ya kurejesha", "recovery-words-instructions": "Andika maneno haya kwa kalamu na karatasi. Usiweke maneno haya kidijitali kwenye simu yako.", - "review-face-confirmation": "Nahakikisha sura yangu inaweza onekana muzuri kweye hiyi video", - "review-voice-confirmation": "Nahakikisha sauti yangu inaweza sikika muzuri kweye hiyi video", - "save-file": "Backup faili", + "review-face-confirmation": "Ninathibitisha kuwa uso wangu unaweza kuonekana vizuri kwenye video hii", + "review-voice-confirmation": "Ninathibitisha kuwa sauti yangu inaweza sikika wazi kwenye video hii", + "save-file": "Hifadhi faili", "save-your-wallet-backup-file-where": "Tunapendekeza uhiweke faili yako katika sehemu tofauti (messaging apps, email, cloud storage, etc.)", "social-backup": "Social Backup", - "social-backup-processing-info-1": "Faili hii ya kurudisha ina backup ya video yako ya urejesho na maelezo kuhusu Jumuiya wako", + "social-backup-processing-info-1": "Faili hii ya kurudisha ina backup ya video yako ya urejesho na maelezo kuhusu Jumuiya yako", "social-backup-processing-info-2": "Faili hiyi peke yake haiwezi kutumika kurudisha makuta yako, kwa kuwa viongozi wataangalia utambulisho wako ile wakati ya kurudisha", "social-backup-video-prompt": "Fedi!", - "start-personal-backup": "Anza kuBackup file yako", - "start-personal-backup-instructions": "Kamata kalamu na karatasi na uandike maneno ya kurudisha kwenye ecran inayofuata", + "start-personal-backup": "Anza kuBackup faili yako", + "start-personal-backup-instructions": "Pata kalamu na karatasi na uandike maneno ya urejeshaji kwenye skrini inayofuata.", "start-recording": "Anza kurekodi", "start-social-backup": "Anza Social Backup", "start-social-backup-instructions": "Backup za kicommunauté hukuruhusu kuBackup mufuko wako kwa usalama na marafiki na familia na kuwa na viongozi wa Jumuiya wakusaidie kurudisha ikiwa utashindwa kujiunga tena kwa mtandao.", - "stop-recording": "Acha kurikodi", - "successfully-backed-up": "Umefaulu kuhifzi backup ya mufuko wako wa Fedi", + "stop-recording": "Acha kurekodi", + "successfully-backed-up": "Umefaulu kuhifadhi backup ya pochi lako la Fedi", "video-file-too-large": "Video ni ya murefu sana, tafazali rekodi engine ya mufupi" }, "bug": { - "description-label": "Elezea tatizo kwa details", - "description-placeholder": "Nini ulukua natarajia na nini ikafanyika badala yake?", - "email-label": "Email (Uki penda)", + "description-label": "Eleza suala hilo kwa undani", + "description-placeholder": "Nini ulikuwa natarajia na nini kilifanyika badala yake?", + "email-label": "Barua pepe (Uki penda)", "info-label": "Tuma Jumuiya na jina la mtumiaji pamoja na repoti ya shida", - "log-disclaimer": "Logs ya app zitawekwa pamoja na ripoti yako ili kusaidia batechnicien wa Fedi kutatua tatizo", + "log-disclaimer": "Logs ya app zitawekwa pamoja na ripoti yako ili kusaidia wasanidi wa Fedi kutatua tatizo", "report-a-bug": "Repoti bug", - "screenshot-label": "Pakia picha za ecran wala rekodi", - "submit-generating-data": "Inatowa data ya ripoti...", + "screenshot-label": "Pakia picha za skrini au rekodi", + "submit-generating-data": "Inatoa data ya ripoti...", "submit-submitting-report": "Inawasilisha ripoti...", "submit-uploading-data": "Inapakia data ya ripoti...", "success-subtitle": "Tutafanya kazi", @@ -269,10 +269,10 @@ "add-an-avatar": "Ongeza avatar", "add-user-as-an-admin": "Weka {{username}} kama admin", "added-admin-to-group": "{{username}} amewekwa kama admin", - "admin-settings": "Reglages ya Admin", + "admin-settings": "Mipangilio ya Admin", "admin-settings-instructions": "Hawa wanamemba wanaweza kutuma jumbe katika vikundi vya utangazaji peke yake", "broadcast-admin-instructions": "Hawa wanamemba wanaweza kutuma jumbe katika vikundi vya utangazaji", - "broadcast-admin-settings": "Reglages ya Admin wa matangazo", + "broadcast-admin-settings": "Mipangilio ya Admin wa matangazo", "broadcast-admins": "Admins wa matangazo", "broadcast-only": "Matangazo peke yake", "camera-access-information": "Kujiunga na kikundi, utahitaji kuruhusu Fedi kutumiya kamera yako ili kutambua lien ya mwaliko ya kikundi.", @@ -280,77 +280,77 @@ "change-role": "Badilisha jukumu", "change-role-failure": "Imeshindwa kubadilisha jukumu", "change-role-success": "Imefaulu kubadilisha jukumu", - "chat-invite": "Mwaliko ya kuzungumuza", - "click-to-join-group": "Finya ili kujiunga na hiyi kikundi", - "copied-group-invite-code": "Imenakili code ya mwaliko ya kikundi", - "create-a-display-name": "Tengeneza jina ya kuonesha", + "chat-invite": "Mwaliko wa kuzungumuza", + "click-to-join-group": "finya ili kujiunga na kikundi hiki", + "copied-group-invite-code": "Imenakili code ya mwaliko wa kikundi", + "create-a-display-name": "Tengeneza jina ya kuonyesha", "create-a-group": "Tengeneza kikundi", "create-group": "Tengeneza kikundi", - "create-or-join-a-new-group": "Tengeneza wala jiunge na kikundi ya mupya", - "disappearing-messages": "Jumbe za kupoteya", - "display-name": "Onyesha jina", - "display-name-guidance": "Unaweza badilisha iyi badaaye", + "create-or-join-a-new-group": "Tengeneza au jiunge na kikundi kipya", + "disappearing-messages": "Jumbe za kutoweka", + "display-name": "Jina la kuonyesha", + "display-name-guidance": "Unaweza badilisha hii badaaye", "edit-group": "Badilisha kikundi", "enter-a-username": "Weka jina ya mtumiaji", - "enter-display-name": "Ingiza jina ya kuonyesha", - "fedi-community": "communauté ya Fedi", + "enter-display-name": "Weka jina la kuonyesha", + "fedi-community": "Jumuiya ya Fedi", "fedi-community-message-preview": "Karibu kwenye Fedi! Kikundi hiki kitakupa habari kuhusu matukio yanayotokea ndani ya app yako ya Fedi", - "go-to-direct-chat": "Wende kwenye mazungumzo moja kwa moja", - "group-invite": "Mwaliko ya kikundi", - "group-name": "Jina ya kikundi", - "group-not-found": "Hakuna kikundi imepatikana", + "go-to-direct-chat": "Enda kwenye gumzo la moja kwa moja", + "group-invite": "Mwaliko wa kikundi", + "group-name": "Jina la kikundi", + "group-not-found": "Hakuna kikundi kimepatikana", "join-a-group": "Jiunge na kikundi", "leave-group": "Toka kwenye kikundi", "member-not-found": "Haikuweza kupata mwanamemba aliye na jina la mtumiaji '{{username}}'", "need-registration-title": "Kuwa tayari kuzungumza", - "new-chat": "Mazungumzo ya mupya", - "new-group": "Kikundi ya mupya", - "new-messages": "Jumbe za mupya", + "new-chat": "Gumzo mpya", + "new-group": "Kikundi kipya", + "new-messages": "Jumbe mapya", "no-admins": "Hakuna bado Admins wa matangazo", - "no-one-is-in-this-group": "Hakuya kuwa bado mwanamemba katika hiki chumba ", + "no-one-is-in-this-group": "Bado hakuna aliye kwenye kundi hili", "no-users-found": "Hakuna watumiaji waliopatikana", - "open-camera-scanner": "Fungula scanner ya kamera", + "open-camera-scanner": "Fungua scanner ya kamera", "other-sent-payment": "{{name}} imetumwa {{recipient}} {{fiat}} ({{amount}}) {{memo}}", "paid-by-name": "Imelipiwa na {{name}}", - "paste-group-invite": "Bandika mwaliko ya kikundi", - "register-a-username": "Andikisha jina ya mtumiaji", - "removed-admin-from-group": "Ametowa {{username}} kama Admin", - "room-settings": "Reglages ya chumba", - "scan-chat-invite": "Scanner mwaliko ya mazungumzo", - "scan-group-invite": "Scanner mwaliko ya kikundi", - "scan-member-code-notice": "Onesha QR ili upatiyane jina yako ya mtumiaji", + "paste-group-invite": "Bandika mwaliko wa kikundi", + "register-a-username": "Sajili jina la mtumiaji", + "removed-admin-from-group": "Ameondoa {{username}} kama Admin", + "room-settings": "mipangilio ya gumzo", + "scan-chat-invite": "Scan mwaliko wa gumzo", + "scan-group-invite": "Scanner mwaliko wa kikundi", + "scan-member-code-notice": "Onyesha QR ili upatiane jina lako la mtumiaji", "select-or-start": "Chagua mazungumzo, wala anzisha mazungumzo ya mupya", "send-a-message-to": "Tuma ujumbe kwa {{name}}", - "show-history-to-new-members": "Onesha historia kwa wanamemba wa mupya", + "show-history-to-new-members": "Onyesha historia kwa wanamemba wapya", "start-the-conversation": "Anza mazungumzo", - "they-requested-payment": "{{name}} alilomba {{fiat}} ({{amount}}) {{memo}}", - "they-sent-payment": "{{name}} alikutumiya {{fiat}} ({{amount}}) {{memo}}", - "try-inviting-someone": "Jaribu kualika mutu", + "they-requested-payment": "{{name}} aliomba {{fiat}} ({{amount}}) {{memo}}", + "they-sent-payment": "{{name}} alikutumia {{fiat}} ({{amount}}) {{memo}}", + "try-inviting-someone": "Jaribu kualika mtu", "type-to-search-members": "Andika ili utafute wanamemba katika hiki kikundi", "unknown-member": "Mwanamemba asiye julikana", "upgrade-chat": "Boresha mazungumzo", - "upgrade-chat-guidance": "Tumefanya mazungumzo ya Fedi kuwa salama zaidi na yaweze kutumika kwa watu katika communauté nyingi", - "upgrade-chat-item-subtitle-1": "Ongelesha mutu yeyote kwenye Fedi", + "upgrade-chat-guidance": "Tumefanya mazungumzo ya Fedi kuwa salama zaidi na yaweze kutumika kwa watu katika jamii nyingi", + "upgrade-chat-item-subtitle-1": "Ongea na mtu yeyote kwenye Fedi", "upgrade-chat-item-subtitle-2": "Npubs kwa wote", "upgrade-chat-item-subtitle-3": "Mwisho hadi mwisho", "upgrade-chat-item-subtitle-4": "Tuma & pokea bitcoin", "upgrade-chat-item-subtitle-5": "Soon™", - "upgrade-chat-item-title-1": "Mazungumzo ya communauté mingi", - "upgrade-chat-item-title-2": "kuonesha jina moja ya", + "upgrade-chat-item-title-1": "Mazungumzo ya jamii nyingi", + "upgrade-chat-item-title-2": "Jina moja la kuonyesha", "upgrade-chat-item-title-3": "Ujumbe wa moja kwa moja uliofichwa", "upgrade-chat-item-title-4": "Malipo rahisi", - "upgrade-chat-item-title-5": "Yamingi inakuya!", - "view-group": "Angaliya kikundi", - "view-shared-media": "Angaliya vitu zenye zilitumiwa", - "waiting-for-network": "Tunangojea mtandao...", - "you-requested-payment": "Ulilomba {{fiat}} ({{amount}}) {{memo}}", + "upgrade-chat-item-title-5": "Mengi yanakuja!", + "view-group": "Angalia kikundi", + "view-shared-media": "Tazama midia iliyoshirikiwa", + "waiting-for-network": "Inasubiri mtandao...", + "you-requested-payment": "Uliomba {{fiat}} ({{amount}}) {{memo}}", "you-sent-payment": "Ulituma {{fiat}} ({{amount}}) {{memo}}" }, "developer": { - "developer-mode-activated": "Sasa wewe ni mwandishi wa app!", + "developer-mode-activated": "Sasa wewe ni msanidi programu!", "developer-mode-deactivated": "Sasa wewe si mwandishi wa app tena...", "download-logs": "Pakua logs", - "export-transactions-csv": "Hamisha matumizi ya makuta kama CSV", + "export-transactions-csv": "Hamisha shughuli za CSV", "logs": "Logs", "nightly": "Nightly", "share-logs": "Tumia wengine logs", @@ -359,44 +359,44 @@ "federations": { "add-federation": "Ongeza Jumuiya", "camera-access-information": "Ili kujiunga na Jumuiya, utahitaji kuruhusu Fedi kufikia kamera yako ili kutambua code ya mwaliko wa Jumuiya", - "copied-federation-invite": "Lien ya mwaliko ya Jumuiya imenakiliwa", - "enter-federation-code": "Ingiza code ya Jumuiya", + "copied-federation-invite": "Linki ya mwaliko wa jumuiya kimenakiliwa", + "enter-federation-code": "Ingiza msimbo wa Jumuiya", "federation-details": "Maelezo ya Jumuiya", - "federation-invite": "Mwaliko ya Jumuiya", - "federation-terms": "Masharti ya Jumuiya", + "federation-invite": "Mwaliko wa Jumuiya", + "federation-terms": "Masharti za Jumuiya", "invite-members": "Alika wanamemba", - "join-federation": "Jiunge na Jumuiya ya mupya", + "join-federation": "Jiunge na Jumuiya mpya", "leave-federation": "Toka kwenye Jumuiya", "leave-federation-confirmation": "Uko na uhakika unataka kutoka kwenye huu Jumuiya?", - "leave-federation-withdraw-first": "Lazima utoshe makuta zako kabla ya kutoka", - "leave-federation-withdraw-pending-stable-first": "Utowaji wako wa {{currency}} bado unaendelea kuangaliwa. Tafazali jaribu tena baada ya 10 minutes.", - "leave-federation-withdraw-stable-first": "Ni lazima utoe salio yako yote ya {{currency}} kabla ya kutoka.", - "paste-federation-code": "Bandika code ya Jumuiya", - "paste-federation-code-instead": "Bandika code wa Jumuiya badala yake", - "scan-federation-invite": "scanner Mwaliko ya Jumuiya" + "leave-federation-withdraw-first": "Lazima utoe pesa zako kabla ya kuondoka", + "leave-federation-withdraw-pending-stable-first": "Utoaji wako wa {{currency}} bado unaendelea kuangaliwa. Tafadhali jaribu tena baada ya dakika 10.", + "leave-federation-withdraw-stable-first": "Ni lazima utoe salio lako lote la {{currency} kabla ya kuondoka.", + "paste-federation-code": "Bandika msimbo wa Jumuiya", + "paste-federation-code-instead": "Bandika msimbo wa Jumuiya badala yake", + "scan-federation-invite": "Scan mwaliko wa Jumuiya" }, "fedimods": { "add-a-mod": "Ongeza mod", "add-fedi-mod": "Ongeza Fedi Mod", - "add-mods-homescreen": "Ongeza Mods kwenye ecran yako ya mwanzo ya Fedi", + "add-mods-homescreen": "Ongeza Mods kwenye skrini yako ya nyumbani ya Fedi", "debug-mode": "Hali ya kutatua mafaili ya Fedi Mod", "debug-mode-info": "Ingiza zana ya dev (Eruda) kwenye kivinjari wakati wa kufungua Fedi Mods", - "enter-amount-to-withdraw": "Weka montant ya makuta ya kutowa, kuanzia {{fediMod}}", + "enter-amount-to-withdraw": "Weka kiasi cha pesa ili utoe kwenye {{fediMod}}", "fedi-mods": "Mods za Fedi", - "leave-page": "Kutoka kwenye page?", + "leave-page": "Ungependa kuondoka kwenye ukurasa?", "leave-page-confirmation": "Uko na uhakika unataka toka kwa hii page? Mandiko yako ita potea", "login-failed": "Kuingia inashindikana", "login-to": "Ingia kwa", "mod-title": "Kichwa cha Mod", - "payment-request": "Ombi ya malipo kutoka kwa {{fediMod}}", - "stable-balance-enabled": "Solde stable", - "stable-balance-enabled-info": "Inawezesha uwezo ya kubadilisha sats kuwa makuta stable", - "wants-to-send-you": "{{fediMod}} inataka kukutumia", - "wants-you-to-pay": "{{fediMod}} inataka ulipe", + "payment-request": "Ombi la malipo kutoka kwa {{fediMod}}", + "stable-balance-enabled": "Stable Balance", + "stable-balance-enabled-info": "Inawezesha uwezo ya kubadilisha sats kuwa sarafu stable", + "wants-to-send-you": "{{fediMod}} anataka kukutumia", + "wants-you-to-pay": "{{fediMod}} anataka ulipe", "your-mods": "Mods zako" }, "fees": { - "guidance-lightning": "🤑 Unataka kuokoa makuta kwenye malipo? Ni vizuri uzitume kwenye mazungumzo directement!", + "guidance-lightning": "🤑 Ungependa kuokoa pesa kwenye ada? Tuma kwenye Chat badala yake!", "guidance-onchain": "Kutuma bitcoin kwenye on-chain inaitaji malipo ya mtandao. Kusema kweli, ni bora kwa montant kubwe." }, "nostr": { @@ -411,17 +411,17 @@ "kind-zap": "Zap", "kind-zap-request": "Ombi la Zap", "log-in-to-mod": "Ingia kwa {{fediMod}} na {{method}}", - "wants-you-to-sign": "{{fediMod}} inataka utiye signature" + "wants-you-to-sign": "{{fediMod}} anataka utie sahihi" }, "omni": { - "action-enter-ln-address": "Ingiza adresse ya lightning", + "action-enter-ln-address": "Ingiza anwani ya lightning", "action-enter-text": "Andika maandishi", "action-enter-username": "Andika Jina la mtumiaji", - "action-enter-username-or-ln": "Andika Jina la mtumiaji / Adresse ya Lightning", + "action-enter-username-or-ln": "Andika Jina la mtumiaji / Anwani ya Lightning", "action-paste": "Bandika", - "action-scan": "Scanner QR", + "action-scan": "Scan QR", "action-upload": "Pakia picha ya QR", - "camera-permission-request": "Ruhusu kamera ku scanner.", + "camera-permission-request": "Ruhusu ufikiaji wa kamera ili kuscan.", "confirm-ecash-token": "Hii ni token ya eCash, ungependa kuikomboa?", "confirm-federation-invite": "Hii ni mwaliko ya Jumuiya, ungependa kujiunga?", "confirm-fedi-chat": "Hii ni lien ya mazungumzo, ungependa kwenda huko?", @@ -432,92 +432,92 @@ "search-no-history-header": "Hakuna historia na huyu mtumiaji", "search-no-results": "Hakuna watumiaji wana fanana na “{{query}}”", "search-placeholder-username": "Ingiza jina la mtumiaji", - "search-placeholder-username-or-ln": "Ingiza jina la mtumiaji ao adresse ya lightning", - "unsupported-bolt12": "Ofa za BOLT 12 bado hazitumike. Pole!", + "search-placeholder-username-or-ln": "Ingiza jina la mtumiaji au anwani ya lightning", + "unsupported-bolt12": "Ofa za BOLT 12 bado hazitumiki. Pole!", "unsupported-unknown": "Hmm, hiyo haiko format inatumika kwetu. Pole!" }, "onboarding": { "by-clicking-i-accept": "Kwa kufinya 'Nakubali' unakubaliana na masharti ya huduma kwenye {{tos_url}}", "chat-earn-save-spend": "Ongea, lipua, chunga makuta, na utumie makuta kwa siri na communauté yako", - "community-first": "communauté kwanza", + "community-first": "Jumuiya kwanza", "continue-to-fedi": "Endelea kwa Fedi", "create-username": "Unda jina la mtumiaji", "create-your-username": "Unda jina lako la mtumiaji", - "earn-and-save": "Lipua na Chunga makuta", - "enter-username": "Andika jina ya mtumiaji", + "earn-and-save": "Pata na Uhifadhi", + "enter-username": "Andika jina la mtumiaji", "greeting-instructions": "Sasa utaweza kutuma makuta, kuwasiliana na communauté yako na mengine mengi ukitumia Fedi.", "guidance-1": "Ongea, lipua, chunga makuta, na utumie makuta kwa siri na communauté yako", "guidance-2": "Fedi inatumia communauté yako kufanya iwe rahisi kuchunga makuta na kuhakikisha usalama wa makuta zako, kuzibadilisha kwa makuta ya inchi yako au kupata msaada.", "guidance-3": "Pamoja na Fedi una solde la kibinafsi, malipo na mawasiliano kwa chaguo-msingi, bila kusumbuka.", "guidance-4": "Fedi inatumia bitcoin na mtandao wa lightning kukuunganisha na fursa za kimataifa za kupata makuta mtandaoni kwa masharti yako mwenyewe.", - "guidance-public-federations": "Jaribu communauté kutoka kwenye oroza ya Fedimint ya Kufurahisha.", - "i-accept": "Minaitika", - "i-do-not-accept": "Minakatala", - "join-new-member": "Jiunge kama mwanamemba wa mupya", - "join-returning-member": "Miye ni mwanamemba anayerudiya", + "guidance-public-federations": "Jaribu jumuiya kutoka kwenye orodha ya Awesome Fedimint", + "i-accept": "Nakubali", + "i-do-not-accept": "Sikubali", + "join-new-member": "Jiunge kama mwanamemba mpya", + "join-returning-member": "Mimi ni mwanamemba anayerejea", "new-users-disabled-notice": "Pole, hiyi Jumuiya haipokeye watumiaji wa mupya", "nice-to-meet-you": "Nafurahi kukutana na weye, {{username}}", - "simple-and-private": "Rahisi na binafsi", + "simple-and-private": "Rahisi na ya faragha", "terms-and-conditions": "Sheria na Masharti", - "unsupported-notice": "Hiyi Jumuiya haitumike tena. Hautaweza kujiunga nayo.", - "username-guidance": "Unaweza kubadilisha hiyi baadaye", - "username-instructions": "Jina yako ya mtumiaji litakuwa vile wanamemba wengine watakavyo kutambua.", + "unsupported-notice": "Hii Jumuiya haitumiki tena. Hautaweza kujiunga nayo.", + "username-guidance": "Unaweza kubadilisha hii baadaye", + "username-instructions": "Jina lako la mtumiaji litakuwa jinsi washiriki wengine watakutambulisha.", "welcome-back-to-federation": "Karibu tena kwenye {{federation}}", "welcome-instructions-new": "Kama mwanamemba wa mupya wa Jumuiya, utapokea mufuko ya mupya.", "welcome-instructions-returning": "Kama mwanamemba wa Jumuiya anayerudia, mufuko yako utarudishwa. Hii inaweza kuchukua dakika kidogo.", - "welcome-instructions-unknown": "Wanamemba wa mupya wanapokea mkoba ya mpya. Wanamemba wanao rudia mifuko yao ya zamani itarudishwa automatiquement.", + "welcome-instructions-unknown": "Wanamembaa wapya wanapokea pochi mpya. Wanachama wanaorejea watapata pochi yao ya zamani kiotomatiki.", "welcome-to-federation": "Karibu kwenye {{Jumuiya}}", "welcome-to-fedi": "Karibu kwenye Fedi" }, "parser": { - "unrecognized": "Format ya data hii ayitambulike", - "unsupported-bolt11-zero-amount": "Malipo ya Lightning yenye montant 0 cha makuta hayakubalike. Unda facture ya mupya yenye montant ingine na jaribu tena.", - "unsupported-lnurl": "Aina ya LNURL ayitumike hii '{{type}}'" + "unrecognized": "Format ya data isiyotambulika", + "unsupported-bolt11-zero-amount": "Kiasi cha sufuri kwenye ankara za lightning hazitumiki. Tengeneza ankara mpya iliyo na kiasi cha pesa na ujaribu tena.", + "unsupported-lnurl": "Aina ya LNURL isiyotumika '{{type}}'" }, "permissions": { "allow-camera-description": "Scanner QR codes, zungumza majina ya mtumiaji, tuma makuta, na mengine mengi", - "allow-camera-title": "Ruhusu kamera kufika kwa", + "allow-camera-title": "Ruhusu ufikiaji wa kamera", "allow-notifications-description": "Ujumbe mpya za mazungumzo, malipo, na matangazo", "allow-notifications-title": "Ruhusu arifa kuonekana", - "update-later-disclaimer": "Hii inaweza angaliwa tena baadaye" + "update-later-disclaimer": "Hii inaweza kusasishwa baadaye" }, "pin": { "back-up-your-account": "Backup akaunti yako", "backup-notice": "Ikiwa umesahabu PIN yako, backup yako yakurudisha njo njia tuu ya kurudisha akaunti yako.", "change-pin": "Badilisha PIN", "create-a-pin": "Unda PIN", - "create-new-pin": "Unda PIN ya mupya", + "create-new-pin": "Unda PIN mpya", "enter-current-pin": "Weka PIN ya sasa", "enter-pin": "Weka PIN", - "pin-access": "Kufikia PIN", - "pin-doesnt-match": "PIN haiambatane", - "pin-setup-successful": "Kuweka PIN kumefanikiwa!", + "pin-access": "Ufikiaji wa pin", + "pin-doesnt-match": "PIN hailingani", + "pin-setup-successful": "Usanidi wa PIN umefaulu!", "re-enter-pin": "Weka tena PIN", "unlocking-fedi-app": "Kufungua app ya Fedi" }, "popup": { "ended": "Imemalizika", - "ended-description": "Hiyi Jumuiya ya muda imeisha {{date}}", - "ending-description": "Hiyi Jumuiya ya muda itaisha {{date}}. Makuta zilizobaki zitasimamiwa kwa hiari ya Admins.", - "ending-in": "Inaisha ndani ya {{time}}" + "ended-description": "Jumuiya hii ya muda imeisha {{date}}.", + "ending-description": "Jumuiya hii ya muda itaisha {{date}}. Fedha zilizobaki zitasimamiwa kwa hiari ya walezi.", + "ending-in": "Inaisha kwa {{time}}" }, "receive": { - "add-amount": "Ongeza montant ya makuta", - "awaiting-deposit": "Inangojea kuweka makuta", - "awaiting-withdrawal-from": "Inangojea kutowa makuta kwenye {{domain}}...", - "balance-not-spendable-offline": "Solde hiyi haitaweza kutumiwa paka urudiye mtandaoni", + "add-amount": "Ongeza kiwango cha pesa", + "awaiting-deposit": "Inasubiri amana", + "awaiting-withdrawal-from": "Inasubiri kutoa kwenye", + "balance-not-spendable-offline": "Salio hili halitaweza kutumika hadi urudi mtandaoni", "bitcoin-request": "Ombi la Bitcoin", "camera-access-information": "Ili kupokea makuta inje ya mtandao, utahitaji kuruhusu Fedi kutumiya kamera yako ili iweze kuScanner code ya malipo inje ya mtandao", - "copied-payment-code": "Ombi ya malipo imenakiliwa", - "create-lightning-request": "Unda ombi ya Lightning", - "enable-onchain-deposits": "Wezesha kuweka makuta kwa onchain", - "instructions": "Andika ni makuta ngapi unataka kupokeya", - "maximum-invoice-amount": "Montant maximum ya malipo ni {{maxAmount}} SATS", - "onchain-notice": "Kuweka makuta kutumiya On-chain inachukua karibu ~10 heures kuzibitishwa. Tumia Lightning kwa malipo ya direct", - "pending-transaction": "Matumizi ya makuta inayosubiri", + "copied-payment-code": "Ombi la malipo limenakiliwa", + "create-lightning-request": "Unda ombi la Lightning", + "enable-onchain-deposits": "Washa amana za onchain", + "instructions": "Weka kiasi unachotaka kupokea", + "maximum-invoice-amount": "Kiasi cha juu cha ankara ni {{maxAmount}} SATS", + "onchain-notice": "Amana za mtandaoni huchukua ~ saa 10 kuthibitishwa. Tumia lightning kwa shughuli za papo hapo", + "pending-transaction": "Shughuli inayosubiri", "receive-amount-unit": "Pokea {{amount}} {{unit}}", "receive-bitcoin": "Pokea bitcoin", - "receive-bitcoin-offline": "Pokea bitcoin inje ya mtandao", + "receive-bitcoin-offline": "Pokea bitcoin nje ya mtandao", "request-bitcoin": "Omba Bitcoin", "request-sats": "Omba {{amount}} SATS", "request-via-lightning": "Omba kupitia Lightning", @@ -527,95 +527,95 @@ }, "recovery": { "camera-access-information": "Ili kusaidia katika urudishaji, utahitaji kuruhusu Fedi kutumia kamera yako ili ku scanner code ya Social Recovery ", - "cancel-social-recovery": "Katala Social recovery", - "cancel-social-recovery-detail": "Ungelitaka ku Katala Social recovery?", - "choose-method": "Chagula njia ya urudishwaji", - "choose-method-instructions": "Chagula njia uliyotumia kuBackup mufuko yako ulipojiunga kwa mara ya kwanza na {{federation}}", - "choose-wallet-option": "Chagula chaguo ya mufuko", + "cancel-social-recovery": "Ghairi social recovery", + "cancel-social-recovery-detail": "Ungependa kughairi social recovery?", + "choose-method": "Chagua njia ya kurejesha", + "choose-method-instructions": "Chagua njia uliyotumia kuhifadhi nakala ya pochi lako ulipojiunga kwa mara ya kwanza {{shirikisho}}", + "choose-wallet-option": "Chagua chaguo la pochi", "complete-social-recovery": "Maliza Social recovery", - "create-a-new-wallet": "Unda mufuko ya mupya", - "create-a-new-wallet-instead": "Unda mufuko ya mupya badala yake", - "create-new-wallet": "Unda mufuko ya mupya", + "create-a-new-wallet": "Unda pochi mpya", + "create-a-new-wallet-instead": "Unda pochi mpya badala yake", + "create-new-wallet": "Unda pochi mpya", "create-new-wallet-guidance": "Hiyi Mufuko itaweza kutimika tu kwenye hiyi simu, isipokuwa uirudishe na kuhamisha kwenye simu ingine", "download-failed": "Imeshindwa download", - "fresh-wallet": "Mufuko ya mupya kwenye hiyi simu", - "from-different-device": "Kutoka kwenye simu tofauti hadi kwa hiyi", - "guardian-approval-instructions": "Viongozi wanahitaji kuhakikisha kama weye ndiye mtu uko katika video yako ya kwanza ulipo jiunga na Fedi ili urudishe makuta zako", - "guardian-approval-step-1": "1. Panga mikutano na Viongozi (ona Viongozi hapa chini)", + "fresh-wallet": "Pochi mpya kwenye kifaa hiki", + "from-different-device": "Kutoka kwa kifaa tofauti hadi hiki", + "guardian-approval-instructions": "Walinzi wanahitaji kuthibitisha kuwa wewe ndiye mtu katika Video yako ya Utangulizi wa Fedi ili kurejesha pesa zako", + "guardian-approval-step-1": "1. Panga mikutano na walinzi (ona walinzi hapa chini)", "guardian-approval-step-2": "2. Uliza muhongozi ascanner code yako ya QR", "guardian-approval-step-3": "3. Wataangalia video yako na kuhakikisha utambulisho wako", "guardian-approval-step-4": "4. Kama watahakikisha, makuta yako itarudishwa kwenye mufuko yako", - "guardian-approvals": "Autorisation ya viongozi", - "guardian-qr-instructions": "Viongozi watahitaji ku scanner hiyi QR code ili kuanza Social Recovery", + "guardian-approvals": "Idhini za walinzi", + "guardian-qr-instructions": "Viongozi watahitaji ku scan hii QR code ili kuanza Social Recovery", "guardians-remaining": "{{guardians}} walio baki", - "invalid-qr-code": "Code ya social recovery isiyo tumika", + "invalid-qr-code": "Msimbo batili wa social recovery", "locate-social-recovery-file": "Fungula faili yako ya kuBackup", - "locate-social-recovery-instructions-1": "Inaweza kuwa umetuma hiyi faili kwa marafiki au uka iBackup katika sehemu kadhaa tofauti:", - "locate-social-recovery-instructions-3": "Jina ya Faili ya Fedi inafanana kama:", + "locate-social-recovery-instructions-1": "Huenda umeshiriki faili hii na marafiki au kuihifadhi katika maeneo machache tofauti:", + "locate-social-recovery-instructions-3": "Jina la Faili ya Fedi inafanana kama:", "locate-social-recovery-instructions-check-1": "Download", - "locate-social-recovery-instructions-check-2": "UBackup wa Wingu", + "locate-social-recovery-instructions-check-2": "Hifadhi ya wingu", "locate-social-recovery-instructions-check-3": "Ujumbe", "locate-social-recovery-instructions-check-4": "etc", - "locked-device-guidance-1": "Mufuko kwenye simu hiyi umehamishiwa kwenye simu engine.", - "locked-device-guidance-2": "Hiyi simu {{deviceName}} sasa imefungwa. ❌", + "locked-device-guidance-1": "Pochi kwenye kifaa hiki limehamishiwa kwenye kifaa kingine.", + "locked-device-guidance-2": "Kifaa hiki {{deviceName}} sasa kimefungwa. ❌", "locked-device-guidance-3": "Ikiwa unataka kuweka mufuko mwengine kwenye hiyi simu, tafazali vuta na uweke tena Fedi. 👍", - "new-wallet": "Mufuko ya mupya", - "nothing-to-download": "Hakuna kitu ya download kutoka kwa muhongozi", - "open-qr-code": "Fungula QR Code", - "opening-backup-file-failed": "Imeshindwa kufungula faili ya kuBackup", - "opening-backup-file-failed-instructions": "UBackup wa faili {{fileName}} ilishindwa kufunguliwa. Tafazali jaribu kurudisha kutoka mahali kwengine uliko ituma.", - "paste-social-recovery-code": "Bandika code ya Social Recovery", - "paste-social-recovery-code-instead": "Bandika code ya Social Recovery badala yake", - "personal-recovery": "Urudishaji wa kibinafsi", - "personal-recovery-instructions": "Ingiza maneno 12 uliyo yaandika wakati ulipoBackup mufuko yako kwa mara ya kwanza", - "personal-recovery-method": "Ikiwa uliyaandika maneno 12 ya urudishaji unaweza kuyatumia tena kurudisha mufuko yako", - "recover-a-wallet": "Rudisha tena mufuko", - "recover-wallet": "Rudisha tena mufuko", - "recover-wallet-with-balance": "Urudishwaji haufanyi kazi kwa sasa kwa mifuko iliyo na makuta. Tafazali tosha makuta zako kwanza.", + "new-wallet": "Pochi mpya", + "nothing-to-download": "Hakuna cha kupakua kutoka kwa mlezi", + "open-qr-code": "Fungua QR Code", + "opening-backup-file-failed": "Imeshindwa kufungua faili ya backup", + "opening-backup-file-failed-instructions": "Faili ya backup {{fileName}} imeshindwa kufunguliwa. Tafadhali jaribu na kurejesha kutoka kwa eneo lingine uliloshiriki.", + "paste-social-recovery-code": "Bandika msimbo wa Social Recovery", + "paste-social-recovery-code-instead": "Bandika msimbo wa Social Recovery", + "personal-recovery": "Ahueni ya kibinafsi", + "personal-recovery-instructions": "Andika maneno 12 uliyoandika ulipoweka nakala rudufu ya pochi lako kwa mara ya kwanza", + "personal-recovery-method": "Ikiwa uliandika maneno 12 ya kurejesha unaweza kuyaweka tena ili kurejesha pochi lako", + "recover-a-wallet": "Rejesha pochi", + "recover-wallet": "Rejesha pochi", + "recover-wallet-with-balance": "Urejeshaji kwa sasa hautumiki kwa pochi zilizo na salio. Tafadhali toa hela zako kwanza.", "recovering-your-wallet": "Unarudisha mufuko yako, tafazali angalia tena baadaye", - "recovery-assist": "Msaada wa urudishaji", + "recovery-assist": "Msaada wa kurejesha", "recovery-assist-confirm-check-1": "Mwanamemba mwenye iko na weye yuko sawa, ametuliya na hana wasiwasi", "recovery-assist-confirm-check-2": "Mazingira ya karibu ya huyu mutu ni salama", - "recovery-assist-description": "Mwanamemba wa Jumuiya yako anahitaji msaada wako katika kurudisha mufuko wake", - "recovery-assist-instructions-1": "1. Angaliya mwanamemba ni mtulivu na salama", - "recovery-assist-instructions-2": "2. Scanner QR code ya wanamemba", - "recovery-assist-instructions-3": "3. Angaliya video yao ya kuBackup ya Fedi", - "recovery-assist-instructions-4": "4. Hakikisha mutu ni ule ule katika video ya kuBack upya", - "recovery-assist-instructions-5": "5. Itikia wala katala ombi ya kurusha ya wanamemba", - "recovery-assist-process": "Usaidizi wa urudishwaji", - "recovery-assist-thank-you": "Asante kwa kusaidia kurudisha makuta kwa mwanamemba wa Jumuiya", - "recovery-confirm-identity-instructions-1": "Angaliya video ili kuhakikisha utambulisho wa mwanamemba.", + "recovery-assist-description": "Mwanachama wa Jumuiya yako anaomba usaidizi wako ili kurejesha pochi lake", + "recovery-assist-instructions-1": "1. Angalia kuwa mwanachama ni mtulivu na salama", + "recovery-assist-instructions-2": "2. Scan msimbo wa QR wa wanachama", + "recovery-assist-instructions-3": "3. Tazama video yao ya kubackup Fedi", + "recovery-assist-instructions-4": "4. Thibitisha kuwa wao ni mtu sawa katika video ya backup", + "recovery-assist-instructions-5": "5. Idhinisha au ukatae ombi la kurejesha wanachama", + "recovery-assist-process": "Mchakato wa usaidizi wa kurejesha", + "recovery-assist-thank-you": "Asante kwa kusaidia kurejesha pesa kwa mwanachama wa Jumuiya", + "recovery-confirm-identity-instructions-1": "Tazama video ili kuthibitisha utambulisho wa mwanachama.", "recovery-confirm-identity-instructions-2": "Je, video hii inaonesha ule ule mwanamemba anayejaribu Social Recovery sasa ivi?", "recovery-confirm-identity-no": "Apana, mutu katika video ni tafauti kabisa na mwanamemba", - "recovery-in-progress-chat-payments": "Urudishaji unaendelea. Malipo ya mazungumzo yatakuwa tayari hivi karibuni.", - "recovery-in-progress-payments": "Urudishaji unaendelea. Malipo yatakuwa tayari hivi karibuni.", + "recovery-in-progress-chat-payments": "Urejeshaji unaendelea. Malipo ya gumzo yatapatikana hivi karibuni.", + "recovery-in-progress-payments": "Urejeshaji unaendelea. Malipo yatapatikana hivi karibuni.", "search-files": "Tafuta faili", - "select-a-device": "Chagula simu", - "select-a-device-guidance": "Chagua simu ya kuhamishaya amo mufuko yenye ilikua kutoka zamani", + "select-a-device": "Chagua kifaa", + "select-a-device-guidance": "Chagua kifaa cha kuhamisha pochi iliyopo kutoka zamani", "social-recovery-steps": "Hatua za Social Recovery", - "social-recovery-unsuccessful": "Social Recovery haukufaulu", - "social-recovery-unsuccessful-instructions": "Jaribu kurudiya kwa viongozi na kuhakikisha utambulisho wako", - "successfully-opened-fedi-file": "Imefungula Faili yako ya Fedi", - "transfer-existing-wallet": "Hamisha mufuko yenye iko", - "transfer-existing-wallet-guidance-1": "Hii italeta mufuko wako uliopo kwenye hiyi simu na kufunga mufuko kwenye ile simu engine.", - "transfer-existing-wallet-guidance-2": "Hauhitaji simu ya zamani ili kutuma", - "wallet-transfer": "Uhamisho wa mufuko", - "wallet-was-transferred": "Mufuko ulihamishiwa" + "social-recovery-unsuccessful": "Social recovery haikufaulu", + "social-recovery-unsuccessful-instructions": "Jaribu kurudi kwa walezi na kuthibitisha utambulisho wako", + "successfully-opened-fedi-file": "Imefungua Faili yako ya Fedi", + "transfer-existing-wallet": "Hamisha pochi uliopo", + "transfer-existing-wallet-guidance-1": "Hii italeta pochi lako uliopo kwenye kifaa hiki na kufunga pochi kwenye kifaa kingine.", + "transfer-existing-wallet-guidance-2": "Huhitaji kifaa cha zamani ili kuhamisha.", + "wallet-transfer": "Uhamisho wa pochi", + "wallet-was-transferred": "Pochi ulihamishiwa" }, "send": { - "confirm-send": "Hakikisha kutuma", - "enter-payment-request": "Ingiza ombi la malipo", + "confirm-send": "Thibitisha kutuma", + "enter-payment-request": "Weka ombi la malipo", "hold-to-confirm-send": "Shikilia ili kuhakikisha kutuma", - "i-have-sent-payment": "Nimeshatuma malipo", + "i-have-sent-payment": "Nimetuma malipo", "paste-payment-request-instead": "Bandika ombi la malipo badala yake", - "refund-in-block": "Kurudishiwa makuta kwenye block {{block}}", + "refund-in-block": "Kurejesha pesa kwenye block", "send-amount-unit": "Tuma {{amount}} {{unit}}", "send-bitcoin": "Tuma bitcoin", - "send-bitcoin-offline": "Tuma bitcoin inje ya mtandao", + "send-bitcoin-offline": "Tuma bitcoin nje ya mtandao", "send-from": "Tuma kutoka", - "send-offline": "Tume inje ya mtandao", + "send-offline": "Tume nje ya mtandao", "send-to": "Tuma kwa", - "send-to-offline-user": "Tuma kwa mtumiaji mwenye iko inje ya mtandao", + "send-to-offline-user": "Tuma kwa mtumiaji wa nje ya mtandao", "you-sent-amount-unit": "Ulituma {{amount}} {{unit}}" }, "settings": { @@ -627,7 +627,7 @@ "brl": "Real ya Brazil", "bwp": "Pula ya Botswana", "cdf": "Franc ya Congo", - "cfa": "Franc ya Jamuhuri ya Africa ya Kati", + "cfa": "Franc ya Jamhuri ya Africa ya Kati", "clp": "Peso ya Chile", "cop": "Peso ya Kolombia", "cup": "Peso ya Cuba", @@ -635,9 +635,9 @@ "djf": "Franc ya Djibouti", "ern": "Nakfa ya Eritrea", "etb": "Birr ya Ethiopia", - "eur": "Euro ya Umoja ya Ulaya", + "eur": "Euro ya Umoja wa Ulaya", "ghs": "Cedi ya Ghana", - "gtq": " Quetzal ya Guatemala", + "gtq": "Quetzal ya Guatemala", "hkd": "Dola ya Hong Kong", "hnl": "Lempira ya Honduras", "idr": "Rupiah ya Indonesia", @@ -660,53 +660,53 @@ "ugx": "Shilingi ya Uganda", "usd": "Dola ya Marekani", "uyu": "Peso ya Uruguai", - "ves": "Bolivares", + "ves": "Venezuelan Sovereign Bolívar", "vnd": "Dong ya Vietnam", - "xaf": "Cameroon", + "xaf": "Franc ya CFA ya Afrika ya Kati", "zmw": "Kwacha ya Zambia" } }, "stabilitypool": { - "amount-may-vary": "montant ya makuta inaweza kubadilika", - "amount-pending": "{{amount}} inangoya", - "available-to-deposit": "Tayari kwa kuweka", - "bitcoin-amount": "montant ya Bitcoin", - "bitcoin-balance": "Solde ya Bitcoin", - "confirm-deposit": "Hakikisha kuweka", - "confirm-withdrawal": "Hakikisha utowaji", - "currency-balance": "{{currency}} Solde", - "current-value": "Samani ya sasa", - "deposit-amount": "Weka montant ya makuta", - "deposit-from": "Weka kuanzia", - "deposit-intiated": "Kuweka imeanzishwa", - "deposit-pending": "+{{currency}} kuweka makuta inangojeya...", + "amount-may-vary": "Kiasi kinaweza kutofautiana", + "amount-pending": "{{amount}} inasubiri", + "available-to-deposit": "Inapatikana kwa kuweka", + "bitcoin-amount": "Kiasi cha Bitcoin", + "bitcoin-balance": "Usawa wa Bitcoin", + "confirm-deposit": "Thibitisha kuweka", + "confirm-withdrawal": "Thibitisha uondoaji", + "currency-balance": "{{sarafu}} salio", + "current-value": "Thamani ya sasa", + "deposit-amount": "Kiasi cha kuweka", + "deposit-from": "Kuweka kutoka kwa", + "deposit-intiated": "Kuweka kumeanzishwa", + "deposit-pending": "{{kiasi}} kuweka pesa inasubiri......", "deposit-time": "Wakati wa kuweka", - "deposit-to": "Weka makuta kwa", - "deposit-to-balance": "Weka makuta kwa {{currency}} solde", - "enter-withdrawal-amount": "Ingiza montant ya makuta ya kutowa", + "deposit-to": "Amana kwa", + "deposit-to-balance": "Amana kwa salio la {{sarafu}}", + "enter-withdrawal-amount": "Weka kiasi cha kutoa", "minutes": "{{minutes}} dakika", "more-than-an-hour": "Masaa 1+", - "one-minute": "Dakika 1 ", + "one-minute": "Dakika 1", "one-second": "Sekundi 1", - "pending-withdrawal-blocking": "Tafadhali ngoya uondoaji wa makuta unaosubiri kushugulikiwa kabla ya kuweka makuta zengine wala kutowa makuta zaidi", - "seconds": "{{seconds}} sekundi", + "pending-withdrawal-blocking": "Tafadhali subiri uondoaji unaosubiri kushughulikiwa kabla ya kuweka amana au uondoaji zaidi", + "seconds": "{{seconds}} sekunde", "will-be-deposited": "{{amount}} itawekwa baada ya {{expectedWait}}", "will-be-withdrawn": "{{amount}} itatolewa baada ya {{expectedWait}}", - "withdraw-to": "Towa hadi", - "withdrawal-amount": "montant ya makuta ya kutowa", - "withdrawal-from": "Kutowa makuta kuanziya", - "withdrawal-from-balance": "Kutowa makuta kuanziya {{currency}} solde", - "withdrawal-intiated": "Kutowa makuta imeanzishwa", - "withdrawal-pending": "{{amount}} kutowa makuta inangojea...", - "withdrawal-time": "Wakati wa utoaji makuta", - "withdrawal-value": "Samani ya utoaji makuta", + "withdraw-to": "Toa kwa", + "withdrawal-amount": "Kiasi cha uondoaji", + "withdrawal-from": "Uondoaji kutoka", + "withdrawal-from-balance": "Kuondolewa kwenye salio la {{sarafu}}", + "withdrawal-intiated": "Uondoaji umeanzishwa", + "withdrawal-pending": "Uondoaji {{kiasi}} unasubiri...", + "withdrawal-time": "Muda wa uondoaji", + "withdrawal-value": "Thamani ya uondoaji", "you-deposited": "Uliweka", - "you-withdrew": "Ulitowa" + "you-withdrew": "Ulitoa" }, "wallet": { "network-notice": "Hiyi Jumuiya inatumia {{network}} SATS", - "show-fiat-txn-amounts": "Onesha montant ya makuta za matumizi ya Fiat", - "show-fiat-txn-amounts-info": "Montant ya makuta ya matumizi ya makuta itaoneshwa katika sarafu ya fiat iliyo chaguliwa" + "show-fiat-txn-amounts": "Onyesha Kiasi cha Muamala wa Fiat", + "show-fiat-txn-amounts-info": "Kiasi cha malipo kitaonyeshwa katika sarafu iliyochaguliwa ya fiat" } } } diff --git a/ui/common/redux/chat.ts b/ui/common/redux/chat.ts index d8f5b3f..df0385e 100644 --- a/ui/common/redux/chat.ts +++ b/ui/common/redux/chat.ts @@ -26,7 +26,6 @@ import { Federation, Keypair, XmppClientStatus, - XmppCredentials, } from '../types' import { getFederationChatServerDomain, @@ -59,7 +58,6 @@ const initialFederationChatState = { clientLastOnlineAt: 0, clientError: null as string | null, authenticatedMember: null as ChatMember | null, - credentials: null as XmppCredentials | null, messages: [] as ChatMessage[], groups: [] as ChatGroup[], groupRoles: {} as Record, @@ -586,10 +584,6 @@ const selectFederationChatState = ( federationId || selectActiveFederation(s)?.id || '', ) -/** @deprecated XMPP legacy code */ -export const selectChatCredentials = (s: CommonState) => - selectFederationChatState(s).credentials - /** @deprecated XMPP legacy code */ export const selectChatEncryptionKeys = (s: CommonState) => selectFederationChatState(s).encryptionKeys diff --git a/ui/common/redux/environment.ts b/ui/common/redux/environment.ts index cabad58..f99bb19 100644 --- a/ui/common/redux/environment.ts +++ b/ui/common/redux/environment.ts @@ -1,4 +1,10 @@ -import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit' +import { NetInfoState } from '@react-native-community/netinfo' +import { + createAsyncThunk, + createSelector, + createSlice, + PayloadAction, +} from '@reduxjs/toolkit' import type { i18n } from 'i18next' import { CommonState } from '.' @@ -9,6 +15,7 @@ import { loadFromStorage } from './storage' /*** Initial State ***/ const initialState = { + networkInfo: null as NetInfoState | null, developerMode: false, fedimodDebugMode: false, onchainDepositsEnabled: false, @@ -29,6 +36,9 @@ export const environmentSlice = createSlice({ name: 'environment', initialState, reducers: { + setNetworkInfo(state, action: PayloadAction) { + state.networkInfo = action.payload + }, setDeveloperMode(state, action: PayloadAction) { state.developerMode = action.payload }, @@ -94,6 +104,7 @@ export const environmentSlice = createSlice({ /*** Basic actions ***/ export const { + setNetworkInfo, setDeveloperMode, setFediModDebugMode, setAmountInputType, @@ -141,6 +152,24 @@ export const initializeNostrKeys = createAsyncThunk< /*** Selectors ***/ +export const selectNetworkInfo = (s: CommonState) => s.environment.networkInfo + +/* + * This seemingly complex selector is necessary because we want certainty that + * either there is no network connection or the internet is definitely unreachable. + */ +export const selectIsInternetUnreachable = createSelector( + selectNetworkInfo, + networkInfo => { + if (!networkInfo) return false + if (networkInfo.isConnected === false) return true + // sometimes isInternetReachable is null which does not definitively + // mean the internet is unreachable so explicitly check for false + if (networkInfo.isInternetReachable === false) return true + else return false + }, +) + export const selectDeveloperMode = (s: CommonState) => s.environment.developerMode diff --git a/ui/common/redux/federation.ts b/ui/common/redux/federation.ts index c7f268e..d45b83c 100644 --- a/ui/common/redux/federation.ts +++ b/ui/common/redux/federation.ts @@ -7,11 +7,13 @@ import { import isEqual from 'lodash/isEqual' import omit from 'lodash/omit' import orderBy from 'lodash/orderBy' +import { makeLog } from '../utils/log' import { CommonState, previewCommunityDefaultChats, - previewDefaultGroupChats, + previewGlobalDefaultChats, + selectIsInternetUnreachable, } from '.' import { FEDI_GLOBAL_COMMUNITY } from '../constants/community' import { @@ -20,9 +22,9 @@ import { FederationListItem, FediMod, Guardian, + LoadedFederation, MatrixRoom, MSats, - Network, PublicFederation, Sats, } from '../types' @@ -30,6 +32,7 @@ import { RpcJsonClientConfig, RpcStabilityPoolConfig } from '../types/bindings' import amountUtils from '../utils/AmountUtils' import { coerceFederationListItem, + coerceLoadedFederation, fetchFederationsExternalMetadata, getFederationFediMods, getFederationGroupChats, @@ -38,14 +41,17 @@ import { getFederationMaxStableBalanceMsats, getFederationName, getFederationPinnedMessage, + getFederationStatus, getFederationWelcomeMessage, joinFromInvite, } from '../utils/FederationUtils' import type { FedimintBridge } from '../utils/fedimint' import { makeChatFromPreview } from '../utils/matrix' -import { upsertRecordEntityId } from '../utils/redux' +import { upsertListItem, upsertRecordEntityId } from '../utils/redux' import { loadFromStorage } from './storage' +const log = makeLog('common/redux/federation') + /*** Initial State ***/ const initialState = { @@ -71,49 +77,94 @@ export const federationSlice = createSlice({ initialState, reducers: { setFederations(state, action: PayloadAction) { - state.federations = action.payload + let hasAnyUpdates = false + + const updatedFederations = state.federations.map( + existingFederation => { + const federationToUpsert = action.payload.find( + f => f.id === existingFederation.id, + ) + if (!federationToUpsert) return existingFederation + let updatedFederation: FederationListItem + + switch (federationToUpsert.init_state) { + case 'loading': + case 'failed': + updatedFederation = federationToUpsert + break + case 'ready': + default: + updatedFederation = { + ...existingFederation, + ...federationToUpsert, + } + if ('meta' in federationToUpsert) { + // Merge meta objects, preserving existing fields + const mergedMeta = { + ...('meta' in existingFederation + ? existingFederation.meta + : {}), + ...federationToUpsert.meta, + } + updatedFederation.meta = mergedMeta + } + break + } + + const hasUpdates = !isEqual( + existingFederation, + updatedFederation, + ) + if (hasUpdates) hasAnyUpdates = true + + return hasUpdates ? updatedFederation : existingFederation + }, + ) + + // Add new federations that don't exist in the current state + const newFederations = action.payload.filter( + newFed => + !state.federations.some( + existingFed => existingFed.id === newFed.id, + ), + ) + + if (newFederations.length > 0) { + hasAnyUpdates = true + } + + // Only update state if there were changes + if (hasAnyUpdates) { + state.federations = [...updatedFederations, ...newFederations] + } }, setPublicFederations(state, action: PayloadAction) { state.publicFederations = action.payload }, - updateFederation( - state, - action: PayloadAction>, - ) { - // Only update the array if there were meaningful changes to the federation - let hasUpdates = false - const updatedFederations = state.federations.map(federation => { - if (action.payload.id !== federation.id) return federation - - const updatedFederation = { - ...federation, - ...action.payload, - - // TODO: update reducer to prevent updating a non-wallet - // community with wallet-only properties - } as FederationListItem - hasUpdates = !isEqual(federation, updatedFederation) - return updatedFederation - }) - if (hasUpdates) { - state.federations = updatedFederations - } + upsertFederation(state, action: PayloadAction) { + if (!action.payload.id) return + state.federations = upsertListItem( + state.federations, + action.payload, + ['meta'], + ) }, updateFederationBalance( state, action: PayloadAction<{ federationId: Federation['id'] - balance: Federation['balance'] + balance: LoadedFederation['balance'] }>, ) { const { federationId, balance } = action.payload const federation = state.federations.find( f => f.id === federationId, ) - // No-op if we don't have that federation or it's a + // No-op if we don't have that federation ready, it's a // no-wallet community or balance has not changed if ( !federation || + federation.init_state !== 'ready' || !federation.hasWallet || federation.balance === balance ) @@ -129,19 +180,6 @@ export const federationSlice = createSlice({ setPayFromFederationId(state, action: PayloadAction) { state.payFromFederationId = action.payload }, - updateExternalMeta( - state, - action: PayloadAction, - ) { - const newMeta = { - ...state.externalMeta, - ...action.payload, - } - if (isEqual(newMeta, state.externalMeta)) { - return - } - state.externalMeta = newMeta - }, setFederationCustomFediMods( state, action: PayloadAction<{ @@ -243,12 +281,11 @@ export const federationSlice = createSlice({ export const { setFederations, setPublicFederations, - updateFederation, + upsertFederation, updateFederationBalance, setActiveFederationId, setPayFromFederationId, setFederationCustomFediMods, - updateExternalMeta, setFederationExternalMeta, changeAuthenticatedGuardian, removeCustomFediMod, @@ -262,42 +299,146 @@ export const refreshFederations = createAsyncThunk< { state: CommonState } >('federation/refreshFederations', async (fedimint, { dispatch, getState }) => { const federationsList = await fedimint.listFederations() - const federations: FederationListItem[] = federationsList.map(f => ({ - ...f, - network: f.network as Network, - hasWallet: true as const, - })) - // TODO Check arguments for listCommunities + + log.info(`refreshing ${federationsList.length} federations`) + + const federations: FederationListItem[] = federationsList.map(f => { + let federation: FederationListItem + switch (f.init_state) { + case 'loading': + case 'failed': + federation = { + ...f, + hasWallet: true, + } + return federation + case 'ready': { + const loadedFederation = coerceLoadedFederation(f) + + dispatch( + refreshGuardianStatuses({ + fedimint, + federation: loadedFederation, + }), + ) + return loadedFederation + } + } + }) + const communities = await fedimint.listCommunities({}) const communitiesAsFederations = communities.map(coerceFederationListItem) - const externalMeta = await fetchFederationsExternalMetadata( - [ - ...federations, - ...communitiesAsFederations, - // For the purposes of gathering metadata, we need to - // treat the global community as a "wallet" federation. - // The means we'll fetch the external metadata for it. - { ...FEDI_GLOBAL_COMMUNITY, hasWallet: true }, - ], + const allFederations = [...federations, ...communitiesAsFederations] + dispatch(setFederations(allFederations)) + + // Create externalMeta object directly from federation data since + // bridge does the external meta URL fetching now + // TODO: Remove this along with the refactor to use federation.federations + // as the source of truth for all metadata and can remove the need to maintain + // and update this externalMeta slice in redux + allFederations.map(federation => { + if ( + 'meta' in federation && + federation.meta && + Object.keys(federation.meta).length > 0 + ) { + dispatch( + processFederationMeta({ + federation, + }), + ) + } + }) + // note: this await should only block for 2 seconds maximum. if internet is slow + // it will abort and retry in the background + // TODO: Move the global community meta fetch to the bridge + await fetchFederationsExternalMetadata( + // For the purposes of gathering metadata, we need to + // treat the global community as a "wallet" federation. + // The means we'll fetch the external metadata for it. + [FEDI_GLOBAL_COMMUNITY], (federationId, meta) => { - dispatch(setFederationExternalMeta({ federationId, meta })) dispatch( - setFederationCustomFediMods({ - federationId, - mods: getFederationFediMods(meta), + processFederationMeta({ + federation: { id: federationId, meta }, }), ) }, ) - // First update the federations with the external meta that - // is locally accessible. We update each federation's meta - // as the external fetches return in the background - dispatch(updateExternalMeta(externalMeta)) - dispatch(setFederations([...federations, ...communitiesAsFederations])) return selectFederations(getState()) }) +export const refreshGuardianStatuses = createAsyncThunk< + void, + { fedimint: FedimintBridge; federation: LoadedFederation }, + { state: CommonState } +>( + 'federation/refreshGuardianStatuses', + async ({ fedimint, federation }, { dispatch, getState }) => { + // Don't bother refreshing if we know internet is unreachable + const isInternetUnreachable = selectIsInternetUnreachable(getState()) + log.info( + `refreshing guardian statuses for federation ${getFederationName( + federation, + )}: internet unreachable: ${isInternetUnreachable}`, + ) + if (isInternetUnreachable) return + + // TODO: move this logic to the bridge? + try { + const updatedStatus = await getFederationStatus( + fedimint, + federation.id, + ) + dispatch( + upsertFederation({ + ...federation, + status: updatedStatus, + }), + ) + } catch (error) { + log.error( + `Error in guardian status fetch for federation ${federation.id}:`, + error, + ) + } + }, +) + +export const processFederationMeta = createAsyncThunk< + void, + { federation: Pick }, + { state: CommonState } +>('federation/processFederationMeta', async ({ federation }, { dispatch }) => { + if (!federation.meta) return + + // TODO: Remove this along with the refactor to use federation.federations + // as the source of truth for all metadata and can remove the need to maintain + // and update this externalMeta slice in redux for federation meta + dispatch( + setFederationExternalMeta({ + federationId: federation.id, + meta: federation.meta, + }), + ) + + // fedimods & default chats are derived from the federation meta + dispatch( + setFederationCustomFediMods({ + federationId: federation.id, + mods: getFederationFediMods(federation.meta), + }), + ) + // use a special preview action for the global community since it is + // not stored in redux + if (federation.id === FEDI_GLOBAL_COMMUNITY.id) { + dispatch(previewGlobalDefaultChats()) + } else { + dispatch(previewCommunityDefaultChats(federation.id)) + } +}) + export const joinFederation = createAsyncThunk< FederationListItem, { fedimint: FedimintBridge; code: string; recoverFromScratch?: boolean }, @@ -318,7 +459,7 @@ export const joinFederation = createAsyncThunk< dispatch(setActiveFederationId(federation.id)) // matrix client should be initialized by now // so we can join default groups - dispatch(previewDefaultGroupChats()) + dispatch(previewCommunityDefaultChats(federation.id)) const activeFederation = selectActiveFederation(getState()) if (!activeFederation) throw new Error('errors.unknown-error') @@ -336,69 +477,77 @@ export const leaveFederation = createAsyncThunk< const federation = selectFederation(getState(), federationId) if (!federation) throw new Error('failed-to-leave-federation') - // for communities, the federation id is the invite code - if (!federation.hasWallet) { - await fedimint.leaveCommunity({ inviteCode: federationId }) - return - } - // Fixes https://github.com/fedibtc/fedi/issues/3754 const isRecovering = selectIsAnyFederationRecovering(getState()) if (isRecovering || !federation) throw new Error('failed-to-leave-federation') - if (federation.hasWallet) await fedimint.leaveFederation(federationId) - // for communities, the federation id is the invite code - else fedimint.leaveCommunity({ inviteCode: federationId }) + if (federation.init_state !== 'ready') { + // this handles leaving a federation that has failed to load or is in the process of loading + await fedimint.leaveFederation(federationId) + } else { + if (federation.hasWallet) + await fedimint.leaveFederation(federationId) + // for communities, the federation id is the invite code + else fedimint.leaveCommunity({ inviteCode: federationId }) + } }, ) /*** Selectors ***/ -export const selectWalletFederations = createSelector( +export const selectLoadedFederations = createSelector( (s: CommonState) => s.federation.federations, - (s: CommonState) => s.federation.externalMeta, - (federationListItems, externalMeta) => - federationListItems.flatMap(f => { + federations => + federations.reduce((acc: LoadedFederation[], f: FederationListItem) => { + if (f.init_state === 'ready') { + const loadedFederation: LoadedFederation = { + ...f, + init_state: 'ready', + name: getFederationName(f), + } as LoadedFederation + acc.push(loadedFederation) + } + return acc + }, []), +) + +export const selectWalletFederations = createSelector( + selectLoadedFederations, + loadedFederations => + loadedFederations.flatMap(f => { // Only include wallet federations if (!f.hasWallet) return [] - const meta = externalMeta[f.id] - if (!meta) return [f] - return [ { ...f, - meta, - name: getFederationName(meta) || f.name, + name: getFederationName(f), }, ] - }) as Federation[], + }), ) export const selectFederations = createSelector( (s: CommonState) => s.federation.federations, - (s: CommonState) => s.federation.externalMeta, - (federations, externalMeta) => - federations.map(f => { - const meta = externalMeta[f.id] - if (!meta) { - return f - } - return { - ...f, - meta, - name: getFederationName(meta) || f.name, - } - }), + federations => + federations + .map((f: FederationListItem) => { + return { + ...f, + name: getFederationName(f), + } + }) + // We temporarily filter out failed federations until we have UI designs for this state + .filter(f => f.init_state !== 'failed'), ) export const selectAlphabeticallySortedFederations = createSelector( - selectFederations, + selectLoadedFederations, federations => { return orderBy( federations, - federation => federation.name.toLowerCase(), + federation => federation.name?.toLowerCase() || '', 'asc', ) }, @@ -410,18 +559,37 @@ export const selectFederationIds = createSelector( ) export const selectActiveFederation = createSelector( - selectFederations, + selectLoadedFederations, (s: CommonState) => s.federation.activeFederationId, - (federations, activeFederationId): FederationListItem | undefined => + (federations, activeFederationId): LoadedFederation | undefined => activeFederationId ? federations.find(f => f.id === activeFederationId) || federations[0] : federations[0], ) +export const selectShouldShowDegradedStatus = createSelector( + selectIsInternetUnreachable, + (_s: CommonState, federation: FederationListItem | undefined) => federation, + (isInternetUnreachable, federation) => { + // dont show if there is a local internet problem + if (isInternetUnreachable) return false + const federationStatus = + federation && 'status' in federation ? federation.status : undefined + // dont show if we dont know the status yet + if (!federationStatus) return false + // dont show if the federation is online + if (federationStatus === 'online') return false + else return true + }, +) + export const selectFederation = (s: CommonState, id: string) => selectFederations(s).find(f => f.id === id) +export const selectLoadedFederation = (s: CommonState, id: string) => + selectLoadedFederations(s).find(f => f.id === id) + export const selectActiveFederationId = (s: CommonState) => { return selectActiveFederation(s)?.id } @@ -434,7 +602,7 @@ export const selectPaymentFederation = createSelector( federations, activeFederation, payFromFederationId, - ): Federation | undefined => { + ): LoadedFederation | undefined => { if (!payFromFederationId) { return activeFederation?.hasWallet ? activeFederation : undefined } @@ -549,7 +717,7 @@ export const selectActiveFederationHasWallet = createSelector( ) export const selectIsAnyFederationRecovering = createSelector( - selectFederations, + selectLoadedFederations, federations => { return federations.some(f => f.hasWallet && f.recovering) }, @@ -559,7 +727,7 @@ export const selectFederationCustomFediMods = ( s: CommonState, federationId: Federation['id'], ) => { - const federation = selectFederation(s, federationId) + const federation = selectLoadedFederation(s, federationId) return federation ? s.federation.customFediMods[federation?.id] || [] : [] } diff --git a/ui/common/redux/index.ts b/ui/common/redux/index.ts index 72350bc..dff9eca 100644 --- a/ui/common/redux/index.ts +++ b/ui/common/redux/index.ts @@ -9,8 +9,12 @@ import type { i18n as I18n } from 'i18next' import type { AnyAction } from 'redux' import type { ThunkDispatch } from 'redux-thunk' -import { Federation, StorageApi } from '../types' -import { getMetaUrl } from '../utils/FederationUtils' +import { FederationListItem, StorageApi } from '../types' +import { RpcFederationMaybeLoading } from '../types/bindings' +import { + coerceFederationListItem, + coerceLoadedFederation, +} from '../utils/FederationUtils' import { FedimintBridge } from '../utils/fedimint' import { makeLog } from '../utils/log' import { hasStorageStateChanged } from '../utils/storage' @@ -20,9 +24,11 @@ import { environmentSlice, selectLanguage } from './environment' import { federationSlice, joinFederation, + processFederationMeta, refreshFederations, - updateFederation, + refreshGuardianStatuses, updateFederationBalance, + upsertFederation, } from './federation' import { checkForReceivablePayments, @@ -109,20 +115,65 @@ export function initializeCommonStore({ }) // Update federation on bridge events - const unsubscribeFederation = fedimint.addListener('federation', event => { - // If they have an external meta configured, exclude name and meta from update - const federation: Partial = { ...event } - if (getMetaUrl(event.meta)) { - delete federation.name - delete federation.meta - } - dispatch(updateFederation(federation)) - }) + const unsubscribeFederation = fedimint.addListener( + 'federation', + async (event: RpcFederationMaybeLoading) => { + // don't both updating if the federation isn't ready + // TODO: Should we remove failed federations from the UI? + // if (event.init_state !== 'ready') return + // just in case an erroneous event fires with no id + if (!event.id) return + let federation: FederationListItem + switch (event.init_state) { + // For loading and failes states we just pass it along as-is with hasWallet + case 'loading': + case 'failed': + federation = { + ...event, + hasWallet: true, + } + dispatch(upsertFederation(federation)) + break + // For ready states we prepare the full loaded federation with meta + status updates + case 'ready': { + const loadedFederation = coerceLoadedFederation(event) + dispatch(upsertFederation(loadedFederation)) + if ('meta' in loadedFederation) { + // if the federation_name is found in the meta, overwrite top-level name field + if (loadedFederation.meta.federation_name) { + loadedFederation.name = + loadedFederation.meta.federation_name + } + dispatch( + processFederationMeta({ + federation: loadedFederation, + }), + ) + } + + // also refresh the guardian status when we get a federation update + dispatch( + refreshGuardianStatuses({ + fedimint, + federation: loadedFederation, + }), + ) + break + } + } + }, + ) // Update communities on bridge events const unsubscribeCommunities = fedimint.addListener( 'communityMetadataUpdated', - event => dispatch(updateFederation(event.newCommunity)), + event => { + const federation: FederationListItem = coerceFederationListItem( + event.newCommunity, + ) + dispatch(upsertFederation(federation)) + dispatch(processFederationMeta({ federation })) + }, ) // Update balance on bridge events diff --git a/ui/common/redux/matrix.ts b/ui/common/redux/matrix.ts index 65dc1cb..fe17985 100644 --- a/ui/common/redux/matrix.ts +++ b/ui/common/redux/matrix.ts @@ -3,16 +3,18 @@ import { createAsyncThunk, createSelector, createSlice, + isAnyOf, } from '@reduxjs/toolkit' +import isEqual from 'lodash/isEqual' import orderBy from 'lodash/orderBy' import { v4 as uuidv4 } from 'uuid' import { CommonState, selectAuthenticatedMember, - selectFederation, - selectFederations, selectGlobalCommunityMeta, + selectLoadedFederation, + selectLoadedFederations, selectWalletFederations, } from '.' import { @@ -46,6 +48,7 @@ import { MatrixChatClient } from '../utils/MatrixChatClient' import { FedimintBridge } from '../utils/fedimint' import { makeLog } from '../utils/log' import { + MatrixEventContentType, getReceivablePaymentEvents, getRoomEventPowerLevel, getUserSuffix, @@ -95,6 +98,10 @@ const initialState = { pushNotificationToken: null as string | null, groupPreviews: {} as Record, drafts: {} as Record, + selectedChatMessage: null as MatrixEvent< + MatrixEventContentType<'m.text' | 'm.image' | 'm.video' | 'm.file'> + > | null, + messageToEdit: null as MatrixEvent> | null, } export type MatrixState = typeof initialState @@ -202,6 +209,24 @@ export const matrixSlice = createSlice({ state.drafts[id] = text }, + setSelectedChatMessage( + state, + action: PayloadAction + > | null>, + ) { + state.selectedChatMessage = action.payload + }, + setMessageToEdit( + state, + action: PayloadAction + > | null>, + ) { + state.messageToEdit = action.payload + }, }, extraReducers: builder => { builder.addCase(startMatrixClient.pending, state => { @@ -293,22 +318,6 @@ export const matrixSlice = createSlice({ action.payload }, ) - builder.addCase(previewDefaultGroupChats.fulfilled, (state, action) => { - const updatedDefaultGroups = action.payload.reduce( - ( - result: Record, - preview: MatrixGroupPreview, - ) => { - result[preview.info.id] = { - ...preview, - isDefaultGroup: true, - } - return result - }, - {}, - ) - state.groupPreviews = updatedDefaultGroups - }) builder.addCase(getMatrixRoomPreview.fulfilled, (state, action) => { if (!action.payload) return const existingPreview = state.groupPreviews[action.meta.arg] || {} @@ -317,6 +326,43 @@ export const matrixSlice = createSlice({ ...action.payload, } }) + builder.addMatcher( + isAnyOf( + previewGlobalDefaultChats.fulfilled, + previewCommunityDefaultChats.fulfilled, + previewAllDefaultChats.fulfilled, + ), + (state, action) => { + let hasUpdates = false + const updatedDefaultGroups = action.payload.reduce( + ( + result: Record, + preview: MatrixGroupPreview, + ) => { + const existingPreview = result[preview.info.id] + const updatedPreview = { + ...preview, + isDefaultGroup: true, + } + + if ( + !existingPreview || + !isEqual(existingPreview, updatedPreview) + ) { + hasUpdates = true + result[preview.info.id] = updatedPreview + } + + return result + }, + { ...state.groupPreviews }, + ) + + if (hasUpdates) { + state.groupPreviews = updatedDefaultGroups + } + }, + ) }, }) @@ -337,6 +383,8 @@ export const { handleMatrixRoomTimelineObservableUpdates, resetMatrixState, setChatDraft, + setSelectedChatMessage, + setMessageToEdit, } = matrixSlice.actions /*** Async thunk actions ***/ @@ -597,7 +645,7 @@ export const sendMatrixPaymentPush = createAsyncThunk< { getState }, ) => { const state = getState() - const federation = selectFederation(state, federationId) + const federation = selectLoadedFederation(state, federationId) const matrixAuth = selectMatrixAuth(state) if (!matrixAuth) throw new Error('Not authenticated') if (!federation) throw new Error('Federation not found') @@ -697,12 +745,12 @@ export const checkForReceivablePayments = createAsyncThunk< const timeline = roomId ? state.matrix.roomTimelines[roomId] : // flattens all timelines into 1 array - Object.values(state.matrix.roomTimelines).reduce< - MatrixTimelineItem[] - >((result, t) => { - if (!t) return result - return [...result, ...t] - }, []) + Object.values(state.matrix.roomTimelines).reduce< + MatrixTimelineItem[] + >((result, t) => { + if (!t) return result + return [...result, ...t] + }, []) if (!myId || !timeline) return const walletFederations = selectWalletFederations(getState()) log.info('Looking for receivable payment events...') @@ -934,13 +982,40 @@ export const unbanUser = createAsyncThunk< await client.roomUnbanUser(roomId, userId, reason) }) +export const previewGlobalDefaultChats = createAsyncThunk< + MatrixGroupPreview[], + void, + { state: CommonState } +>('matrix/previewGlobalDefaultChats', async (_, { getState }) => { + const client = getMatrixClient() + // Check the Fedi Global community for default groups + const globalCommunityMeta = selectGlobalCommunityMeta(getState()) + + const globalDefaultChatIds = globalCommunityMeta + ? getFederationGroupChats(globalCommunityMeta) + : [] + log.info( + `Found ${globalDefaultChatIds.length} default groups for global community...`, + ) + + const globalChatResults = await Promise.allSettled( + globalDefaultChatIds.map(client.getRoomPreview), + ) + return globalChatResults.flatMap(preview => + preview.status === 'fulfilled' ? [preview.value] : [], + ) +}) + export const previewCommunityDefaultChats = createAsyncThunk< MatrixGroupPreview[], string, { state: CommonState } >('matrix/previewCommunityDefaultChats', async (federationId, { getState }) => { const client = getMatrixClient() - const federation = selectFederation(getState(), federationId) + // can't fetch previews if matrix init hasn't completed yet + if (!selectMatrixAuth(getState())) return [] + const federation = selectLoadedFederation(getState(), federationId) + // can't fetch preview if the federation is not loaded yet if (!federation) return [] const defaultChats = getFederationGroupChats(federation.meta) log.info( @@ -959,19 +1034,22 @@ export const previewCommunityDefaultChats = createAsyncThunk< }) }) -export const previewDefaultGroupChats = createAsyncThunk< +/** + * Fetches the room previews for any default chats configured in the meta + * of any federations that have been loaded + */ +export const previewAllDefaultChats = createAsyncThunk< MatrixGroupPreview[], void, { state: CommonState } ->('matrix/previewDefaultGroupChats', async (_, { getState, dispatch }) => { - const client = getMatrixClient() - const federations = getState().federation.federations +>('matrix/previewAllDefaultChats', async (_, { getState, dispatch }) => { + const federations = selectLoadedFederations(getState()) // Previews default chats for each federation const federationDefaultChatResults = await Promise.allSettled( // For each federation, return a promise that that resolves to // the result of the dispatched previewCommunityDefaultChats action federations.map(f => { - const federation = selectFederation(getState(), f.id) + const federation = selectLoadedFederation(getState(), f.id) if (!federation) return Promise.reject() return dispatch( previewCommunityDefaultChats(federation.id), @@ -983,23 +1061,7 @@ export const previewDefaultGroupChats = createAsyncThunk< const federationChats = federationDefaultChatResults.flatMap(preview => preview.status === 'fulfilled' ? preview.value : [], ) - // Also check the Fedi Global community for default groups - const globalCommunityMeta = selectGlobalCommunityMeta(getState()) - - const globalDefaultChatIds = globalCommunityMeta - ? getFederationGroupChats(globalCommunityMeta) - : [] - log.info( - `Found ${globalDefaultChatIds.length} default groups for global communiy...`, - ) - - const globalChatResults = await Promise.allSettled( - globalDefaultChatIds.map(client.getRoomPreview), - ) - const globalChats: MatrixGroupPreview[] = globalChatResults.flatMap( - preview => (preview.status === 'fulfilled' ? [preview.value] : []), - ) - return [...federationChats, ...globalChats] + return [...federationChats] }) export const ensureHealthyMatrixStream = createAsyncThunk( @@ -1371,7 +1433,7 @@ export const selectLatestMatrixRoomEventId = ( } export const selectCanPayFromOtherFeds = createSelector( - (s: CommonState) => selectFederations(s), + (s: CommonState) => selectLoadedFederations(s), (s: CommonState, chatPayment: MatrixPaymentEvent) => chatPayment, (federations, chatPayment): boolean => { return !!federations.find( @@ -1384,7 +1446,7 @@ export const selectCanPayFromOtherFeds = createSelector( ) export const selectCanSendPayment = createSelector( - (s: CommonState) => selectFederations(s), + (s: CommonState) => selectLoadedFederations(s), (s: CommonState, chatPayment: MatrixPaymentEvent) => chatPayment, (federations, chatPayment): boolean => { return !!federations.find( @@ -1398,7 +1460,7 @@ export const selectCanSendPayment = createSelector( ) export const selectCanClaimPayment = createSelector( - (s: CommonState) => selectFederations(s), + (s: CommonState) => selectLoadedFederations(s), (s: CommonState, chatPayment: MatrixPaymentEvent) => chatPayment, (federations, chatPayment): boolean => { return !!federations.find( @@ -1408,7 +1470,8 @@ export const selectCanClaimPayment = createSelector( ) export const selectCommunityDefaultRoomIds = createSelector( - (s: CommonState, federationId: string) => selectFederation(s, federationId), + (s: CommonState, federationId: string) => + selectLoadedFederation(s, federationId), federation => { if (!federation) return [] return getFederationGroupChats(federation.meta) @@ -1416,12 +1479,12 @@ export const selectCommunityDefaultRoomIds = createSelector( ) export const selectDefaultMatrixRoomIds = createSelector( - (s: CommonState) => selectFederations(s), + (s: CommonState) => selectLoadedFederations(s), (s: CommonState) => selectGlobalCommunityMeta(s), (federations, globalCommunityMeta) => { let defaultMatrixRoomIds: MatrixRoom['id'][] = federations.reduce( (result: MatrixRoom['id'][], f: FederationListItem) => { - const defaultRoomIds = getFederationGroupChats(f.meta) + const defaultRoomIds = getFederationGroupChats(f.meta || {}) return [...result, ...defaultRoomIds] }, [], @@ -1440,3 +1503,6 @@ export const selectDefaultMatrixRoomIds = createSelector( ) export const selectChatDrafts = (s: CommonState) => s.matrix.drafts +export const selectSelectedChatMessage = (s: CommonState) => + s.matrix.selectedChatMessage +export const selectMessageToEdit = (s: CommonState) => s.matrix.messageToEdit diff --git a/ui/common/redux/transactions.ts b/ui/common/redux/transactions.ts index 010c4a7..b87cece 100644 --- a/ui/common/redux/transactions.ts +++ b/ui/common/redux/transactions.ts @@ -203,6 +203,7 @@ export const selectTransactionHistory = createSelector( Date.now() / 1000 - txn.createdAt > 3600 && (!txn.onchainState || (txn.onchainState.type !== 'waitingForConfirmation' && + txn.onchainState.type !== 'confirmed' && txn.onchainState.type !== 'claimed')) ) { return false diff --git a/ui/common/tests/utils/FederationUtils.test.ts b/ui/common/tests/utils/FederationUtils.test.ts index 1e49607..df67205 100644 --- a/ui/common/tests/utils/FederationUtils.test.ts +++ b/ui/common/tests/utils/FederationUtils.test.ts @@ -1,6 +1,7 @@ import { Federation, FediMod, + LoadedFederation, MSats, Network, SupportedCurrency, @@ -15,7 +16,7 @@ import { const SAMPLE_CHAT_SERVER_DOMAIN = 'chat.dev.fedibtc.com' -const baseFed: Federation = { +const baseFed: LoadedFederation = { id: 'fedid', name: 'testfed', inviteCode: 'tesfedinvitecode', @@ -31,6 +32,8 @@ const baseFed: Federation = { modules: {}, }, hasWallet: true, + status: 'online', + init_state: 'ready', } const fedWithNoMetadata: Federation = { diff --git a/ui/common/tests/utils/redux.test.ts b/ui/common/tests/utils/redux.test.ts new file mode 100644 index 0000000..264baa5 --- /dev/null +++ b/ui/common/tests/utils/redux.test.ts @@ -0,0 +1,146 @@ +import { MSats } from '../../types' +import { + Community, + Federation, + FederationListItem, + LoadedFederation, + Network, + SupportedCurrency, +} from '../../types/fedimint' +import { upsertListItem } from '../../utils/redux' + +const baseFed: LoadedFederation = { + id: 'fedid', + name: 'testfed', + inviteCode: 'tesfedinvitecode', + nodes: { '0': { name: 'alpha', url: 'alphaurl' } }, + balance: 0 as MSats, + recovering: false, + network: Network.regtest, + version: 0, + clientConfig: null, + meta: {}, + fediFeeSchedule: { + remittanceThresholdMsat: 100_000, + modules: {}, + }, + hasWallet: true, + status: 'online', + init_state: 'ready', +} +const testFederation1: Federation = { + ...baseFed, + id: 'id1', + name: 'Federation 1', +} +const testFederation2: Federation = { + ...baseFed, + id: 'id2', + name: 'Federation 2', +} +const testFederation3: Federation = { + ...baseFed, + id: 'id3', + name: 'Federation 3', +} +const testCommunity: Community = { + id: 'id3', + name: 'Community 1', + inviteCode: 'testcommunityinvitecode', + version: 0, + init_state: 'ready', + hasWallet: false, + status: 'online', + meta: {}, + network: undefined, +} +const testFederations: FederationListItem[] = [ + { ...testFederation1 }, + { ...testFederation2 }, +] + +describe('Redux Utils', () => { + describe('upsertListItem', () => { + it('should add a new item to the list if it does not exist', () => { + const result = upsertListItem( + [...testFederations], + testFederation3, + ) + expect(result).toHaveLength(3) + expect(result).toContainEqual(testFederation1) + expect(result).toContainEqual(testFederation2) + expect(result).toContainEqual(testFederation3) + }) + + it('should update an existing item in the list', () => { + const updatedFederation1 = { + ...testFederation1, + name: 'Updated Federation 1', + } + const result = upsertListItem( + [...testFederations], + updatedFederation1, + ) + expect(result).toHaveLength(2) + expect(result).toContainEqual(updatedFederation1) + expect(result).not.toContainEqual(testFederation1) + }) + + it('should return the same list reference if the item is identical', () => { + const list = [testFederation1] + const result = upsertListItem(list, { + ...testFederation1, + name: 'Federation 1', + }) + expect(result).toBe(list) + }) + + const testFederationWithMeta: Federation = { + ...testFederation1, + meta: { 'fedi:pinned_message': 'This is a message' }, + } + const testFederationWithUpdatedMeta: Federation = { + ...testFederation1, + meta: { 'fedi:default_currency': SupportedCurrency.USD }, + } + it('should replace meta when not provided in nestedFields', () => { + const result = upsertListItem( + [testFederationWithMeta], + testFederationWithUpdatedMeta, + ) + expect(result).toHaveLength(1) + expect(result[0].meta).toEqual(testFederationWithUpdatedMeta.meta) + }) + it('should merge meta when provided in nestedFields', () => { + const result = upsertListItem( + [testFederationWithMeta], + testFederationWithUpdatedMeta, + ['meta'], + ) + expect(result).toHaveLength(1) + const resultMeta = result[0].meta + expect(resultMeta).toHaveProperty('fedi:default_currency') + expect(resultMeta).toHaveProperty('fedi:pinned_message') + }) + + it('should sort the list when a sort function is provided', () => { + const sortFn = (entities: FederationListItem[]) => + entities.sort((a, b) => + (a as LoadedFederation).name.localeCompare( + (b as LoadedFederation).name, + ), + ) + const result = upsertListItem( + testFederations, + { ...testCommunity }, + undefined, + sortFn, + ) + expect(result).toEqual([ + { ...testCommunity }, + testFederations[0], + testFederations[1], + ]) + }) + }) +}) diff --git a/ui/common/types/bindings.ts b/ui/common/types/bindings.ts index 09c179a..093e39c 100644 --- a/ui/common/types/bindings.ts +++ b/ui/common/types/bindings.ts @@ -1,1030 +1,1067 @@ -import { RequestInvoiceArgs } from 'webln' +import { RequestInvoiceArgs } from "webln"; -import { MSats } from '@fedi/common/types' +import { MSats } from "@fedi/common/types"; -export type RpcMethodNames = keyof RpcMethods -export type RpcPayload = RpcMethods[M][0] -export type RpcResponse = RpcMethods[M][1] +export type RpcMethodNames = keyof RpcMethods; +export type RpcPayload = RpcMethods[M][0]; +export type RpcResponse = RpcMethods[M][1]; // used by generated code -declare const __opaque_type__: unique symbol // https://blog.beraliv.dev/2021-05-07-opaque-type-in-typescript +declare const __opaque_type__: unique symbol; // https://blog.beraliv.dev/2021-05-07-opaque-type-in-typescript export type Opaque = BaseType & { - readonly [__opaque_type__]: TagName -} + readonly [__opaque_type__]: TagName; +}; -export type EcashRequest = Omit +export type EcashRequest = Omit; // this was auto generated by ts-bindgen.sh export type BackupServiceState = - | { type: 'initializing' } - | { type: 'waiting'; next_backup_timestamp: number | null } - | { type: 'running' } + | { type: "initializing" } + | { type: "waiting"; next_backup_timestamp: number | null } + | { type: "running" }; export interface BackupServiceStatus { - lastBackupTimestamp: number | null - state: BackupServiceState + lastBackupTimestamp: number | null; + state: BackupServiceState; } export interface BalanceEvent { - federationId: RpcFederationId - balance: RpcAmount + federationId: RpcFederationId; + balance: RpcAmount; } export interface CommunityMetadataUpdatedEvent { - newCommunity: RpcCommunity + newCommunity: RpcCommunity; } -export type CreateRoomRequest = any +export type CreateRoomRequest = any; -export type CustomMessageData = Record +export type CustomMessageData = Record; export interface DeviceRegistrationEvent { - state: DeviceRegistrationState + state: DeviceRegistrationState; } export type DeviceRegistrationState = - | 'newDeviceNeedsAssignment' - | 'conflict' - | 'success' - | 'overdue' + | "newDeviceNeedsAssignment" + | "conflict" + | "success" + | "overdue"; export type ErrorCode = - | 'initializationFailed' - | 'notInialized' - | 'badRequest' - | 'alreadyJoined' - | 'invalidInvoice' - | 'invalidMnemonic' - | 'ecashCancelFailed' - | 'panic' - | 'invalidSocialRecoveryFile' - | { insufficientBalance: RpcAmount } - | 'matrixNotInitialized' - | 'unknownObservable' - | 'timeout' - | 'recovery' - | { invalidJson: string } - | 'payLnInvoiceAlreadyPaid' - | 'payLnInvoiceAlreadyInProgress' - | 'noLnGatewayAvailable' + | "initializationFailed" + | "notInialized" + | "badRequest" + | "alreadyJoined" + | "invalidInvoice" + | "invalidMnemonic" + | "ecashCancelFailed" + | "panic" + | "invalidSocialRecoveryFile" + | { insufficientBalance: RpcAmount } + | "matrixNotInitialized" + | "unknownObservable" + | "timeout" + | "recovery" + | { invalidJson: string } + | "payLnInvoiceAlreadyPaid" + | "payLnInvoiceAlreadyInProgress" + | "noLnGatewayAvailable" + | { moduleNotFound: string }; export type Event = - | { transaction: TransactionEvent } - | { log: LogEvent } - | { federation: RpcFederation } - | { balance: BalanceEvent } - | { panic: PanicEvent } - | { stabilityPoolDeposit: StabilityPoolDepositEvent } - | { stabilityPoolWithdrawal: StabilityPoolWithdrawalEvent } - | { recoveryComplete: RecoveryCompleteEvent } - | { recoveryProgress: RecoveryProgressEvent } - | { deviceRegistration: DeviceRegistrationEvent } - | { - stabilityPoolUnfilledDepositSwept: StabilityPoolUnfilledDepositSweptEvent - } - | { communityMetadataUpdated: CommunityMetadataUpdatedEvent } + | { transaction: TransactionEvent } + | { log: LogEvent } + | { federation: RpcFederationMaybeLoading } + | { balance: BalanceEvent } + | { panic: PanicEvent } + | { stabilityPoolDeposit: StabilityPoolDepositEvent } + | { stabilityPoolWithdrawal: StabilityPoolWithdrawalEvent } + | { recoveryComplete: RecoveryCompleteEvent } + | { recoveryProgress: RecoveryProgressEvent } + | { deviceRegistration: DeviceRegistrationEvent } + | { + stabilityPoolUnfilledDepositSwept: StabilityPoolUnfilledDepositSweptEvent; + } + | { communityMetadataUpdated: CommunityMetadataUpdatedEvent }; export type GuardianStatus = - | { online: { guardian: string; latency_ms: number } } - | { error: { guardian: string; error: string } } - | { timeout: { guardian: string; elapsed: string } } + | { online: { guardian: string; latency_ms: number } } + | { error: { guardian: string; error: string } } + | { timeout: { guardian: string; elapsed: string } }; export interface LogEvent { - log: string + log: string; } export interface Observable { - id: number - initial: T + id: number; + initial: T; } -export type ObservableBackPaginationStatus = Observable +export type ObservableBackPaginationStatus = + Observable; -export type ObservableRoomInfo = Observable +export type ObservableRoomInfo = Observable; -export type ObservableRoomList = ObservableVec +export type ObservableRoomList = ObservableVec; -export type ObservableRpcSyncIndicator = Observable +export type ObservableRpcSyncIndicator = Observable; -export type ObservableTimelineItems = ObservableVec +export type ObservableTimelineItems = ObservableVec; export interface ObservableUpdate { - id: number - update_index: number - update: T + id: number; + update_index: number; + update: T; } -export type ObservableVec = Observable> +export type ObservableVec = Observable>; -export type ObservableVecUpdate = ObservableUpdate>> +export type ObservableVecUpdate = ObservableUpdate< + Array> +>; export interface PanicEvent { - message: string + message: string; } export interface RecoveryCompleteEvent { - federationId: RpcFederationId + federationId: RpcFederationId; } export interface RecoveryProgressEvent { - federationId: RpcFederationId - complete: number - total: number + federationId: RpcFederationId; + complete: number; + total: number; } -export type RpcAmount = MSats +export type RpcAmount = MSats; export type RpcAppFlavor = - | { type: 'dev' } - | { type: 'nightly' } - | { type: 'bravo' } + | { type: "dev" } + | { type: "nightly" } + | { type: "bravo" }; export type RpcBackPaginationStatus = - | 'idle' - | 'paginating' - | 'timelineStartReached' + | "idle" + | "paginating" + | "timelineStartReached"; export interface RpcBitcoinDetails { - address: string + address: string; } export interface RpcBridgeStatus { - matrixSetup: boolean - deviceIndexAssignmentStatus: RpcDeviceIndexAssignmentStatus + matrixSetup: boolean; + deviceIndexAssignmentStatus: RpcDeviceIndexAssignmentStatus; } export interface RpcCommunity { - inviteCode: string - name: string - version: number - meta: Record + inviteCode: string; + name: string; + version: number; + meta: Record; } -export type RpcDeviceIndexAssignmentStatus = { assigned: number } | 'unassigned' +export type RpcDeviceIndexAssignmentStatus = + | { assigned: number } + | "unassigned"; export interface RpcDuration { - nanos: number - secs: number + nanos: number; + secs: number; } export interface RpcEcashInfo { - amount: RpcAmount - federationId: RpcFederationId | null + amount: RpcAmount; + federationId: RpcFederationId | null; } export interface RpcFederation { - balance: RpcAmount - id: RpcFederationId - network: string | null - name: string - inviteCode: string - meta: Record - recovering: boolean - nodes: Record - version: number - clientConfig: RpcJsonClientConfig | null - fediFeeSchedule: RpcFediFeeSchedule + balance: RpcAmount; + id: RpcFederationId; + network: string | null; + name: string; + inviteCode: string; + meta: Record; + recovering: boolean; + nodes: Record; + version: number; + clientConfig: RpcJsonClientConfig | null; + fediFeeSchedule: RpcFediFeeSchedule; } -export type RpcFederationId = string +export type RpcFederationId = string; + +export type RpcFederationMaybeLoading = + | { init_state: "loading"; id: RpcFederationId } + | { init_state: "failed"; error: string; id: RpcFederationId } + | ({ init_state: "ready" } & RpcFederation); export interface RpcFederationPreview { - id: RpcFederationId - name: string - meta: Record - inviteCode: string - version: number - returningMemberStatus: RpcReturningMemberStatus + id: RpcFederationId; + name: string; + meta: Record; + inviteCode: string; + version: number; + returningMemberStatus: RpcReturningMemberStatus; } export interface RpcFediFeeSchedule { - remittanceThresholdMsat: number - modules: Record + remittanceThresholdMsat: number; + modules: Record; } export interface RpcFeeDetails { - fediFee: RpcAmount - networkFee: RpcAmount - federationFee: RpcAmount + fediFee: RpcAmount; + networkFee: RpcAmount; + federationFee: RpcAmount; } export interface RpcGenerateEcashResponse { - ecash: string - cancelAt: number + ecash: string; + cancelAt: number; } export interface RpcInitOpts { - dataDir: string | null - logLevel: string | null - deviceIdentifier: string - appFlavor: RpcAppFlavor + dataDir: string | null; + logLevel: string | null; + deviceIdentifier: string; + appFlavor: RpcAppFlavor; } export interface RpcInvoice { - paymentHash: string - amount: RpcAmount - fee: RpcFeeDetails | null - description: string - invoice: string + paymentHash: string; + amount: RpcAmount; + fee: RpcFeeDetails | null; + description: string; + invoice: string; } export interface RpcJsonClientConfig { - global: unknown - modules: Record + global: unknown; + modules: Record; } export interface RpcLightningDetails { - invoice: string - fee: RpcAmount | null + invoice: string; + fee: RpcAmount | null; } export interface RpcLightningGateway { - nodePubKey: RpcPublicKey - gatewayId: RpcPublicKey - api: string - active: boolean + nodePubKey: RpcPublicKey; + gatewayId: RpcPublicKey; + api: string; + active: boolean; } export type RpcLnPayState = - | { type: 'created' } - | { type: 'canceled' } - | { type: 'funded'; block_height: number } - | { type: 'waitingForRefund'; error_reason: string } - | { type: 'awaitingChange' } - | { type: 'success'; preimage: string } - | { type: 'refunded'; gateway_error: string } - | { type: 'failed' } + | { type: "created" } + | { type: "canceled" } + | { type: "funded"; block_height: number } + | { type: "waitingForRefund"; error_reason: string } + | { type: "awaitingChange" } + | { type: "success"; preimage: string } + | { type: "refunded"; gateway_error: string } + | { type: "failed" }; export type RpcLnReceiveState = - | { type: 'created' } - | { type: 'waitingForPayment'; invoice: string; timeout: string } - | { type: 'canceled'; reason: string } - | { type: 'funded' } - | { type: 'awaitingFunds' } - | { type: 'claimed' } + | { type: "created" } + | { type: "waitingForPayment"; invoice: string; timeout: string } + | { type: "canceled"; reason: string } + | { type: "funded" } + | { type: "awaitingFunds" } + | { type: "claimed" }; -export type RpcLnState = RpcLnPayState | RpcLnReceiveState +export type RpcLnState = RpcLnPayState | RpcLnReceiveState; export interface RpcLockedSeek { - currCycleBeginningLockedAmount: RpcAmount - initialAmount: RpcAmount - initialAmountCents: number - withdrawnAmount: RpcAmount - withdrawnAmountCents: number - feesPaidSoFar: RpcAmount - firstLockStartTime: number + currCycleBeginningLockedAmount: RpcAmount; + initialAmount: RpcAmount; + initialAmountCents: number; + withdrawnAmount: RpcAmount; + withdrawnAmountCents: number; + feesPaidSoFar: RpcAmount; + firstLockStartTime: number; } export interface RpcMatrixAccountSession { - userId: string - deviceId: string - displayName: string | null - avatarUrl: string | null + userId: string; + deviceId: string; + displayName: string | null; + avatarUrl: string | null; } export type RpcMatrixMembership = - | 'ban' - | 'invite' - | 'join' - | 'knock' - | 'leave' - | 'unknown' + | "ban" + | "invite" + | "join" + | "knock" + | "leave" + | "unknown"; export interface RpcMatrixUploadResult { - contentUri: string + contentUri: string; } export interface RpcMatrixUserDirectorySearchResponse { - results: Array - limited: boolean + results: Array; + limited: boolean; } export interface RpcMatrixUserDirectorySearchUser { - userId: RpcUserId - displayName: string | null - avatarUrl: string | null + userId: RpcUserId; + displayName: string | null; + avatarUrl: string | null; +} + +export type RpcMediaSource = any; + +export interface RpcMediaUploadParams { + width: number | null; + height: number | null; + mimeType: string; } export interface RpcMethods { - bridgeStatus: [ - Record, - { - matrixSetup: boolean - deviceIndexAssignmentStatus: RpcDeviceIndexAssignmentStatus - }, - ] - onAppForeground: [Record, null] - joinFederation: [ - { inviteCode: string; recoverFromScratch: boolean }, - { - balance: RpcAmount - id: RpcFederationId - network: string | null - name: string - inviteCode: string - meta: Record - recovering: boolean - nodes: Record - version: number - clientConfig: RpcJsonClientConfig | null - fediFeeSchedule: RpcFediFeeSchedule - }, - ] - federationPreview: [ - { inviteCode: string }, - { - id: RpcFederationId - name: string - meta: Record - inviteCode: string - version: number - returningMemberStatus: RpcReturningMemberStatus - }, - ] - leaveFederation: [{ federationId: RpcFederationId }, null] - listFederations: [ - Record, - Array<{ - balance: RpcAmount - id: RpcFederationId - network: string | null - name: string - inviteCode: string - meta: Record - recovering: boolean - nodes: Record - version: number - clientConfig: RpcJsonClientConfig | null - fediFeeSchedule: RpcFediFeeSchedule - }>, - ] - guardianStatus: [ - { federationId: RpcFederationId }, - Array< - | { online: { guardian: string; latency_ms: number } } - | { error: { guardian: string; error: string } } - | { timeout: { guardian: string; elapsed: string } } - >, - ] - generateInvoice: [ - { - federationId: RpcFederationId - amount: RpcAmount - description: string - expiry: number | null - }, - string, - ] - decodeInvoice: [ - { federationId: RpcFederationId | null; invoice: string }, - { - paymentHash: string - amount: RpcAmount - fee: RpcFeeDetails | null - description: string - invoice: string - }, - ] - payInvoice: [ - { federationId: RpcFederationId; invoice: string }, - { preimage: string }, - ] - listGateways: [ - { federationId: RpcFederationId }, - Array<{ - nodePubKey: RpcPublicKey - gatewayId: RpcPublicKey - api: string - active: boolean - }>, - ] - switchGateway: [ - { federationId: RpcFederationId; gatewayId: RpcPublicKey }, - null, - ] - generateAddress: [{ federationId: RpcFederationId }, string] - previewPayAddress: [ - { federationId: RpcFederationId; address: string; sats: bigint }, - { fediFee: RpcAmount; networkFee: RpcAmount; federationFee: RpcAmount }, - ] - payAddress: [ - { federationId: RpcFederationId; address: string; sats: bigint }, - { txid: string }, - ] - generateEcash: [ - { federationId: RpcFederationId; amount: RpcAmount }, - { ecash: string; cancelAt: number }, - ] - receiveEcash: [{ federationId: RpcFederationId; ecash: string }, MSats] - validateEcash: [ - { ecash: string }, - { amount: RpcAmount; federationId: RpcFederationId | null }, - ] - cancelEcash: [{ federationId: RpcFederationId; ecash: string }, null] - listTransactions: [ - { - federationId: RpcFederationId - startTime: number | null - limit: number | null - }, - Array<{ - id: string - createdAt: number - amount: RpcAmount - fediFeeStatus: RpcOperationFediFeeStatus | null - direction: RpcTransactionDirection - notes: string - onchainState: RpcOnchainState | null - bitcoin: RpcBitcoinDetails | null - lnState: RpcLnState | null - lightning: RpcLightningDetails | null - oobState: RpcOOBState | null - onchainWithdrawalDetails: WithdrawalDetails | null - stabilityPoolState: RpcStabilityPoolTransactionState | null - }>, - ] - updateTransactionNotes: [ - { federationId: RpcFederationId; transactionId: string; notes: string }, - null, - ] - getMnemonic: [Record, Array] - checkMnemonic: [{ mnemonic: Array }, boolean] - recoverFromMnemonic: [ - { mnemonic: Array }, - Array<{ - deviceIndex: number - deviceIdentifier: string - lastRegistrationTimestamp: number - }>, - ] - uploadBackupFile: [ - { federationId: RpcFederationId; videoFilePath: string }, - string, - ] - locateRecoveryFile: [Record, string] - validateRecoveryFile: [{ path: string }, null] - recoveryQr: [Record, { recoveryId: RpcRecoveryId } | null] - cancelSocialRecovery: [Record, null] - socialRecoveryApprovals: [ - Record, - { approvals: Array; remaining: number }, - ] - completeSocialRecovery: [ - Record, - Array<{ - deviceIndex: number - deviceIdentifier: string - lastRegistrationTimestamp: number - }>, - ] - socialRecoveryDownloadVerificationDoc: [ - { - federationId: RpcFederationId - recoveryId: RpcRecoveryId - peerId: RpcPeerId - }, - string | null, - ] - approveSocialRecoveryRequest: [ - { - federationId: RpcFederationId - recoveryId: RpcRecoveryId - peerId: RpcPeerId - password: string - }, - null, - ] - signLnurlMessage: [ - { message: string; domain: string }, - { signature: string; pubkey: RpcPublicKey }, - ] - backupStatus: [ - { federationId: RpcFederationId }, - { lastBackupTimestamp: number | null; state: BackupServiceState }, - ] - xmppCredentials: [ - { federationId: RpcFederationId }, - { password: string; keypairSeed: string; username: string | null }, - ] - backupXmppUsername: [ - { federationId: RpcFederationId; username: string }, - null, - ] - getNostrPubkey: [Record, { hex: string; npub: string }] - getNostrSecret: [Record, { hex: string; nsec: string }] - signNostrEvent: [{ eventHash: string }, string] - stabilityPoolAccountInfo: [ - { federationId: RpcFederationId; forceUpdate: boolean }, - { - idleBalance: RpcAmount - stagedSeeks: Array - stagedCancellation: number | null - lockedSeeks: Array - timestamp: number - isFetchedFromServer: boolean - }, - ] - stabilityPoolNextCycleStartTime: [{ federationId: RpcFederationId }, bigint] - stabilityPoolCycleStartPrice: [{ federationId: RpcFederationId }, bigint] - stabilityPoolDepositToSeek: [ - { federationId: RpcFederationId; amount: RpcAmount }, - string, - ] - stabilityPoolWithdraw: [ - { - federationId: RpcFederationId - unlockedAmount: RpcAmount - lockedBps: number - }, - string, - ] - stabilityPoolAverageFeeRate: [ - { federationId: RpcFederationId; numCycles: number }, - bigint, - ] - stabilityPoolAvailableLiquidity: [{ federationId: RpcFederationId }, MSats] - getSensitiveLog: [Record, boolean] - setSensitiveLog: [{ enable: boolean }, null] - setMintModuleFediFeeSchedule: [ - { federationId: RpcFederationId; sendPpm: bigint; receivePpm: bigint }, - null, - ] - setWalletModuleFediFeeSchedule: [ - { federationId: RpcFederationId; sendPpm: bigint; receivePpm: bigint }, - null, - ] - setLightningModuleFediFeeSchedule: [ - { federationId: RpcFederationId; sendPpm: bigint; receivePpm: bigint }, - null, - ] - setStabilityPoolModuleFediFeeSchedule: [ - { federationId: RpcFederationId; sendPpm: bigint; receivePpm: bigint }, - null, - ] - getAccruedOutstandingFediFeesPerTXType: [ - { federationId: RpcFederationId }, - Array<[string, 'receive' | 'send', MSats]>, - ] - getAccruedPendingFediFeesPerTXType: [ - { federationId: RpcFederationId }, - Array<[string, 'receive' | 'send', MSats]>, - ] - dumpDb: [{ federationId: string }, string] - fetchRegisteredDevices: [ - Record, - Array<{ - deviceIndex: number - deviceIdentifier: string - lastRegistrationTimestamp: number - }>, - ] - registerAsNewDevice: [ - Record, - { - balance: RpcAmount - id: RpcFederationId - network: string | null - name: string - inviteCode: string - meta: Record - recovering: boolean - nodes: Record - version: number - clientConfig: RpcJsonClientConfig | null - fediFeeSchedule: RpcFediFeeSchedule - } | null, - ] - transferExistingDeviceRegistration: [ - { index: number }, - { - balance: RpcAmount - id: RpcFederationId - network: string | null - name: string - inviteCode: string - meta: Record - recovering: boolean - nodes: Record - version: number - clientConfig: RpcJsonClientConfig | null - fediFeeSchedule: RpcFediFeeSchedule - } | null, - ] - deviceIndexAssignmentStatus: [ - Record, - { assigned: number } | 'unassigned', - ] - matrixObserverCancel: [{ id: bigint }, null] - matrixInit: [Record, null] - matrixGetAccountSession: [ - { cached: boolean }, - { - userId: string - deviceId: string - displayName: string | null - avatarUrl: string | null - }, - ] - matrixObserveSyncIndicator: [ - Record, - Observable, - ] - matrixRoomList: [Record, ObservableVec] - matrixRoomListUpdateRanges: [{ ranges: RpcRanges }, null] - matrixRoomTimelineItems: [ - { roomId: RpcRoomId }, - ObservableVec, - ] - matrixRoomTimelineItemsPaginateBackwards: [ - { roomId: RpcRoomId; eventNum: number }, - null, - ] - matrixRoomObserveTimelineItemsPaginateBackwards: [ - { roomId: RpcRoomId }, - Observable, - ] - matrixSendMessage: [{ roomId: RpcRoomId; message: string }, null] - matrixSendMessageJson: [ - { - roomId: RpcRoomId - msgtype: string - body: string - data: CustomMessageData - }, - null, - ] - matrixRoomCreate: [{ request: CreateRoomRequest }, string] - matrixRoomCreateOrGetDm: [{ userId: RpcUserId }, string] - matrixRoomJoin: [{ roomId: RpcRoomId }, null] - matrixRoomJoinPublic: [{ roomId: RpcRoomId }, null] - matrixRoomLeave: [{ roomId: RpcRoomId }, null] - matrixRoomObserveInfo: [{ roomId: RpcRoomId }, Observable] - matrixRoomInviteUserById: [{ roomId: RpcRoomId; userId: RpcUserId }, null] - matrixRoomSetName: [{ roomId: RpcRoomId; name: string }, null] - matrixRoomSetTopic: [{ roomId: RpcRoomId; topic: string }, null] - matrixRoomGetMembers: [ - { roomId: RpcRoomId }, - Array<{ - userId: RpcUserId - displayName: string | null - avatarUrl: string | null - powerLevel: number - membership: RpcMatrixMembership - }>, - ] - matrixUserDirectorySearch: [ - { searchTerm: string; limit: number }, - { results: Array; limited: boolean }, - ] - matrixSetDisplayName: [{ displayName: string }, null] - matrixSetAvatarUrl: [{ avatarUrl: string }, null] - matrixUploadMedia: [ - { path: string; mimeType: string }, - { contentUri: string }, - ] - matrixRoomGetPowerLevels: [{ roomId: RpcRoomId }, any] - matrixRoomSetPowerLevels: [ - { roomId: RpcRoomId; new: RpcRoomPowerLevelsEventContent }, - null, - ] - matrixRoomSendReceipt: [{ roomId: RpcRoomId; eventId: string }, boolean] - matrixRoomSetNotificationMode: [ - { roomId: RpcRoomId; mode: RpcRoomNotificationMode }, - null, - ] - matrixRoomGetNotificationMode: [ - { roomId: RpcRoomId }, - 'allMessages' | 'mentionsAndKeywordsOnly' | 'mute' | null, - ] - matrixSetPusher: [{ pusher: RpcPusher }, null] - matrixUserProfile: [{ userId: RpcUserId }, any] - matrixRoomKickUser: [ - { roomId: RpcRoomId; userId: RpcUserId; reason: string | null }, - null, - ] - matrixRoomBanUser: [ - { roomId: RpcRoomId; userId: RpcUserId; reason: string | null }, - null, - ] - matrixRoomUnbanUser: [ - { roomId: RpcRoomId; userId: RpcUserId; reason: string | null }, - null, - ] - matrixIgnoreUser: [{ userId: RpcUserId }, null] - matrixUnignoreUser: [{ userId: RpcUserId }, null] - matrixRoomPreviewContent: [ - { roomId: RpcRoomId }, - Array< - | { kind: 'event'; value: RpcTimelineItemEvent } - | { kind: 'dayDivider'; value: number } - | { kind: 'readMarker' } - | { kind: 'unknown' } - >, - ] - matrixPublicRoomInfo: [{ roomId: string }, any] - matrixRoomMarkAsUnread: [{ roomId: RpcRoomId; unread: boolean }, null] - communityPreview: [ - { inviteCode: string }, - { - inviteCode: string - name: string - version: number - meta: Record - }, - ] - joinCommunity: [ - { inviteCode: string }, - { - inviteCode: string - name: string - version: number - meta: Record - }, - ] - leaveCommunity: [{ inviteCode: string }, null] - listCommunities: [ - Record, - Array<{ - inviteCode: string - name: string - version: number - meta: Record - }>, - ] + bridgeStatus: [ + Record, + { + matrixSetup: boolean; + deviceIndexAssignmentStatus: RpcDeviceIndexAssignmentStatus; + }, + ]; + onAppForeground: [Record, null]; + joinFederation: [ + { inviteCode: string; recoverFromScratch: boolean }, + { + balance: RpcAmount; + id: RpcFederationId; + network: string | null; + name: string; + inviteCode: string; + meta: Record; + recovering: boolean; + nodes: Record; + version: number; + clientConfig: RpcJsonClientConfig | null; + fediFeeSchedule: RpcFediFeeSchedule; + }, + ]; + federationPreview: [ + { inviteCode: string }, + { + id: RpcFederationId; + name: string; + meta: Record; + inviteCode: string; + version: number; + returningMemberStatus: RpcReturningMemberStatus; + }, + ]; + leaveFederation: [{ federationId: RpcFederationId }, null]; + listFederations: [ + Record, + Array< + | { init_state: "loading"; id: RpcFederationId } + | { init_state: "failed"; error: string; id: RpcFederationId } + | ({ init_state: "ready" } & RpcFederation) + >, + ]; + guardianStatus: [ + { federationId: RpcFederationId }, + Array< + | { online: { guardian: string; latency_ms: number } } + | { error: { guardian: string; error: string } } + | { timeout: { guardian: string; elapsed: string } } + >, + ]; + generateInvoice: [ + { + federationId: RpcFederationId; + amount: RpcAmount; + description: string; + expiry: number | null; + }, + string, + ]; + decodeInvoice: [ + { federationId: RpcFederationId | null; invoice: string }, + { + paymentHash: string; + amount: RpcAmount; + fee: RpcFeeDetails | null; + description: string; + invoice: string; + }, + ]; + payInvoice: [ + { federationId: RpcFederationId; invoice: string }, + { preimage: string }, + ]; + listGateways: [ + { federationId: RpcFederationId }, + Array<{ + nodePubKey: RpcPublicKey; + gatewayId: RpcPublicKey; + api: string; + active: boolean; + }>, + ]; + switchGateway: [ + { federationId: RpcFederationId; gatewayId: RpcPublicKey }, + null, + ]; + generateAddress: [{ federationId: RpcFederationId }, string]; + previewPayAddress: [ + { federationId: RpcFederationId; address: string; sats: bigint }, + { fediFee: RpcAmount; networkFee: RpcAmount; federationFee: RpcAmount }, + ]; + payAddress: [ + { federationId: RpcFederationId; address: string; sats: bigint }, + { txid: string }, + ]; + generateEcash: [ + { federationId: RpcFederationId; amount: RpcAmount }, + { ecash: string; cancelAt: number }, + ]; + receiveEcash: [{ federationId: RpcFederationId; ecash: string }, MSats]; + validateEcash: [ + { ecash: string }, + { amount: RpcAmount; federationId: RpcFederationId | null }, + ]; + cancelEcash: [{ federationId: RpcFederationId; ecash: string }, null]; + updateCachedFiatFXInfo: [ + { fiatCode: string; btcToFiatHundredths: bigint }, + null, + ]; + listTransactions: [ + { + federationId: RpcFederationId; + startTime: number | null; + limit: number | null; + }, + Array<{ + id: string; + createdAt: number; + amount: RpcAmount; + fediFeeStatus: RpcOperationFediFeeStatus | null; + direction: RpcTransactionDirection; + notes: string; + onchainState: RpcOnchainState | null; + bitcoin: RpcBitcoinDetails | null; + lnState: RpcLnState | null; + lightning: RpcLightningDetails | null; + oobState: RpcOOBState | null; + onchainWithdrawalDetails: WithdrawalDetails | null; + stabilityPoolState: RpcStabilityPoolTransactionState | null; + txDateFiatInfo: TransactionDateFiatInfo | null; + }>, + ]; + updateTransactionNotes: [ + { federationId: RpcFederationId; transactionId: string; notes: string }, + null, + ]; + backupNow: [{ federationId: RpcFederationId }, null]; + getMnemonic: [Record, Array]; + checkMnemonic: [{ mnemonic: Array }, boolean]; + recoverFromMnemonic: [ + { mnemonic: Array }, + Array<{ + deviceIndex: number; + deviceIdentifier: string; + lastRegistrationTimestamp: number; + }>, + ]; + uploadBackupFile: [ + { federationId: RpcFederationId; videoFilePath: string }, + string, + ]; + locateRecoveryFile: [Record, string]; + validateRecoveryFile: [{ path: string }, null]; + recoveryQr: [Record, { recoveryId: RpcRecoveryId } | null]; + cancelSocialRecovery: [Record, null]; + socialRecoveryApprovals: [ + Record, + { approvals: Array; remaining: number }, + ]; + completeSocialRecovery: [ + Record, + Array<{ + deviceIndex: number; + deviceIdentifier: string; + lastRegistrationTimestamp: number; + }>, + ]; + socialRecoveryDownloadVerificationDoc: [ + { + federationId: RpcFederationId; + recoveryId: RpcRecoveryId; + peerId: RpcPeerId; + }, + string | null, + ]; + approveSocialRecoveryRequest: [ + { + federationId: RpcFederationId; + recoveryId: RpcRecoveryId; + peerId: RpcPeerId; + password: string; + }, + null, + ]; + signLnurlMessage: [ + { message: string; domain: string }, + { signature: string; pubkey: RpcPublicKey }, + ]; + backupStatus: [ + { federationId: RpcFederationId }, + { lastBackupTimestamp: number | null; state: BackupServiceState }, + ]; + getNostrPubkey: [Record, { hex: string; npub: string }]; + getNostrSecret: [Record, { hex: string; nsec: string }]; + signNostrEvent: [{ eventHash: string }, string]; + stabilityPoolAccountInfo: [ + { federationId: RpcFederationId; forceUpdate: boolean }, + { + idleBalance: RpcAmount; + stagedSeeks: Array; + stagedCancellation: number | null; + lockedSeeks: Array; + timestamp: number; + isFetchedFromServer: boolean; + }, + ]; + stabilityPoolNextCycleStartTime: [{ federationId: RpcFederationId }, bigint]; + stabilityPoolCycleStartPrice: [{ federationId: RpcFederationId }, bigint]; + stabilityPoolDepositToSeek: [ + { federationId: RpcFederationId; amount: RpcAmount }, + string, + ]; + stabilityPoolWithdraw: [ + { + federationId: RpcFederationId; + unlockedAmount: RpcAmount; + lockedBps: number; + }, + string, + ]; + stabilityPoolAverageFeeRate: [ + { federationId: RpcFederationId; numCycles: number }, + bigint, + ]; + stabilityPoolAvailableLiquidity: [{ federationId: RpcFederationId }, MSats]; + getSensitiveLog: [Record, boolean]; + setSensitiveLog: [{ enable: boolean }, null]; + setMintModuleFediFeeSchedule: [ + { federationId: RpcFederationId; sendPpm: bigint; receivePpm: bigint }, + null, + ]; + setWalletModuleFediFeeSchedule: [ + { federationId: RpcFederationId; sendPpm: bigint; receivePpm: bigint }, + null, + ]; + setLightningModuleFediFeeSchedule: [ + { federationId: RpcFederationId; sendPpm: bigint; receivePpm: bigint }, + null, + ]; + setStabilityPoolModuleFediFeeSchedule: [ + { federationId: RpcFederationId; sendPpm: bigint; receivePpm: bigint }, + null, + ]; + getAccruedOutstandingFediFeesPerTXType: [ + { federationId: RpcFederationId }, + Array<[string, "receive" | "send", MSats]>, + ]; + getAccruedPendingFediFeesPerTXType: [ + { federationId: RpcFederationId }, + Array<[string, "receive" | "send", MSats]>, + ]; + dumpDb: [{ federationId: string }, string]; + fetchRegisteredDevices: [ + Record, + Array<{ + deviceIndex: number; + deviceIdentifier: string; + lastRegistrationTimestamp: number; + }>, + ]; + registerAsNewDevice: [ + Record, + { + balance: RpcAmount; + id: RpcFederationId; + network: string | null; + name: string; + inviteCode: string; + meta: Record; + recovering: boolean; + nodes: Record; + version: number; + clientConfig: RpcJsonClientConfig | null; + fediFeeSchedule: RpcFediFeeSchedule; + } | null, + ]; + transferExistingDeviceRegistration: [ + { index: number }, + { + balance: RpcAmount; + id: RpcFederationId; + network: string | null; + name: string; + inviteCode: string; + meta: Record; + recovering: boolean; + nodes: Record; + version: number; + clientConfig: RpcJsonClientConfig | null; + fediFeeSchedule: RpcFediFeeSchedule; + } | null, + ]; + deviceIndexAssignmentStatus: [ + Record, + { assigned: number } | "unassigned", + ]; + matrixObserverCancel: [{ id: bigint }, null]; + matrixInit: [Record, null]; + matrixGetAccountSession: [ + { cached: boolean }, + { + userId: string; + deviceId: string; + displayName: string | null; + avatarUrl: string | null; + }, + ]; + matrixObserveSyncIndicator: [ + Record, + Observable, + ]; + matrixRoomList: [Record, ObservableVec]; + matrixRoomListUpdateRanges: [{ ranges: RpcRanges }, null]; + matrixRoomTimelineItems: [ + { roomId: RpcRoomId }, + ObservableVec, + ]; + matrixRoomTimelineItemsPaginateBackwards: [ + { roomId: RpcRoomId; eventNum: number }, + null, + ]; + matrixRoomObserveTimelineItemsPaginateBackwards: [ + { roomId: RpcRoomId }, + Observable, + ]; + matrixSendMessage: [{ roomId: RpcRoomId; message: string }, null]; + matrixSendMessageJson: [ + { + roomId: RpcRoomId; + msgtype: string; + body: string; + data: CustomMessageData; + }, + null, + ]; + matrixSendAttachment: [ + { + roomId: RpcRoomId; + filename: string; + filePath: string; + params: RpcMediaUploadParams; + }, + null, + ]; + matrixRoomCreate: [{ request: CreateRoomRequest }, string]; + matrixRoomCreateOrGetDm: [{ userId: RpcUserId }, string]; + matrixRoomJoin: [{ roomId: RpcRoomId }, null]; + matrixRoomJoinPublic: [{ roomId: RpcRoomId }, null]; + matrixRoomLeave: [{ roomId: RpcRoomId }, null]; + matrixRoomObserveInfo: [{ roomId: RpcRoomId }, Observable]; + matrixRoomInviteUserById: [{ roomId: RpcRoomId; userId: RpcUserId }, null]; + matrixRoomSetName: [{ roomId: RpcRoomId; name: string }, null]; + matrixRoomSetTopic: [{ roomId: RpcRoomId; topic: string }, null]; + matrixRoomGetMembers: [ + { roomId: RpcRoomId }, + Array<{ + userId: RpcUserId; + displayName: string | null; + avatarUrl: string | null; + powerLevel: number; + membership: RpcMatrixMembership; + }>, + ]; + matrixUserDirectorySearch: [ + { searchTerm: string; limit: number }, + { results: Array; limited: boolean }, + ]; + matrixSetDisplayName: [{ displayName: string }, null]; + matrixSetAvatarUrl: [{ avatarUrl: string }, null]; + matrixUploadMedia: [ + { path: string; mimeType: string }, + { contentUri: string }, + ]; + matrixRoomGetPowerLevels: [{ roomId: RpcRoomId }, any]; + matrixRoomSetPowerLevels: [ + { roomId: RpcRoomId; new: RpcRoomPowerLevelsEventContent }, + null, + ]; + matrixRoomSendReceipt: [{ roomId: RpcRoomId; eventId: string }, boolean]; + matrixRoomSetNotificationMode: [ + { roomId: RpcRoomId; mode: RpcRoomNotificationMode }, + null, + ]; + matrixRoomGetNotificationMode: [ + { roomId: RpcRoomId }, + "allMessages" | "mentionsAndKeywordsOnly" | "mute" | null, + ]; + matrixSetPusher: [{ pusher: RpcPusher }, null]; + matrixUserProfile: [{ userId: RpcUserId }, any]; + matrixRoomKickUser: [ + { roomId: RpcRoomId; userId: RpcUserId; reason: string | null }, + null, + ]; + matrixRoomBanUser: [ + { roomId: RpcRoomId; userId: RpcUserId; reason: string | null }, + null, + ]; + matrixRoomUnbanUser: [ + { roomId: RpcRoomId; userId: RpcUserId; reason: string | null }, + null, + ]; + matrixIgnoreUser: [{ userId: RpcUserId }, null]; + matrixUnignoreUser: [{ userId: RpcUserId }, null]; + matrixRoomPreviewContent: [ + { roomId: RpcRoomId }, + Array< + | { kind: "event"; value: RpcTimelineItemEvent } + | { kind: "dayDivider"; value: number } + | { kind: "readMarker" } + | { kind: "unknown" } + >, + ]; + matrixPublicRoomInfo: [{ roomId: string }, any]; + matrixRoomMarkAsUnread: [{ roomId: RpcRoomId; unread: boolean }, null]; + matrixEditMessage: [ + { roomId: RpcRoomId; eventId: string; newContent: string }, + null, + ]; + matrixDeleteMessage: [ + { roomId: RpcRoomId; eventId: string; reason: string | null }, + null, + ]; + matrixDownloadFile: [{ path: string; mediaSource: RpcMediaSource }, string]; + matrixStartPoll: [ + { roomId: RpcRoomId; question: string; answers: Array }, + null, + ]; + matrixEndPoll: [{ roomId: RpcRoomId; pollStartId: string }, null]; + matrixRespondToPoll: [ + { roomId: RpcRoomId; pollStartId: string; selections: Array }, + null, + ]; + communityPreview: [ + { inviteCode: string }, + { + inviteCode: string; + name: string; + version: number; + meta: Record; + }, + ]; + joinCommunity: [ + { inviteCode: string }, + { + inviteCode: string; + name: string; + version: number; + meta: Record; + }, + ]; + leaveCommunity: [{ inviteCode: string }, null]; + listCommunities: [ + Record, + Array<{ + inviteCode: string; + name: string; + version: number; + meta: Record; + }>, + ]; } export interface RpcModuleFediFeeSchedule { - sendPpm: number - receivePpm: number + sendPpm: number; + receivePpm: number; } export interface RpcNostrPubkey { - hex: string - npub: string + hex: string; + npub: string; } export interface RpcNostrSecret { - hex: string - nsec: string + hex: string; + nsec: string; } export type RpcOOBReissueState = - | { type: 'created' } - | { type: 'issuing' } - | { type: 'done' } - | { type: 'failed'; error: string } + | { type: "created" } + | { type: "issuing" } + | { type: "done" } + | { type: "failed"; error: string }; export type RpcOOBSpendState = - | { type: 'created' } - | { type: 'userCanceledProcessing' } - | { type: 'userCanceledSuccess' } - | { type: 'userCanceledFailure' } - | { type: 'refunded' } - | { type: 'success' } + | { type: "created" } + | { type: "userCanceledProcessing" } + | { type: "userCanceledSuccess" } + | { type: "userCanceledFailure" } + | { type: "refunded" } + | { type: "success" }; -export type RpcOOBState = RpcOOBSpendState | RpcOOBReissueState +export type RpcOOBState = RpcOOBSpendState | RpcOOBReissueState; export type RpcOnchainDepositState = - | { type: 'waitingForTransaction' } - | ({ type: 'waitingForConfirmation' } & RpcOnchainDepositTransactionData) - | ({ type: 'claimed' } & RpcOnchainDepositTransactionData) - | { type: 'failed' } + | { type: "waitingForTransaction" } + | ({ type: "waitingForConfirmation" } & RpcOnchainDepositTransactionData) + | ({ type: "confirmed" } & RpcOnchainDepositTransactionData) + | ({ type: "claimed" } & RpcOnchainDepositTransactionData) + | { type: "failed" }; export interface RpcOnchainDepositTransactionData { - txid: string + txid: string; } -export type RpcOnchainState = RpcOnchainDepositState | RpcOnchainWithdrawState +export type RpcOnchainState = RpcOnchainDepositState | RpcOnchainWithdrawState; export type RpcOnchainWithdrawState = - | { type: 'created' } - | { type: 'succeeded' } - | { type: 'failed' } + | { type: "created" } + | { type: "succeeded" } + | { type: "failed" }; export type RpcOperationFediFeeStatus = - | { type: 'pendingSend'; fedi_fee: RpcAmount } - | { type: 'pendingReceive'; fedi_fee_ppm: number } - | { type: 'success'; fedi_fee: RpcAmount } - | { type: 'failedSend'; fedi_fee: RpcAmount } - | { type: 'failedReceive'; fedi_fee_ppm: number } + | { type: "pendingSend"; fedi_fee: RpcAmount } + | { type: "pendingReceive"; fedi_fee_ppm: number } + | { type: "success"; fedi_fee: RpcAmount } + | { type: "failedSend"; fedi_fee: RpcAmount } + | { type: "failedReceive"; fedi_fee_ppm: number }; -export type RpcOperationId = string +export type RpcOperationId = string; export interface RpcPayAddressResponse { - txid: string + txid: string; } export interface RpcPayInvoiceResponse { - preimage: string + preimage: string; } -export type RpcPeerId = number +export type RpcPeerId = number; -export type RpcPublicKey = string +export type RpcPublicKey = string; -export type RpcPublicRoomChunk = any +export type RpcPublicRoomChunk = any; -export type RpcPusher = any +export type RpcPusher = any; -export type RpcRanges = Array<{ start: number; end: number }> +export type RpcRanges = Array<{ start: number; end: number }>; -export type RpcRecoveryId = string +export type RpcRecoveryId = string; export interface RpcRegisteredDevice { - deviceIndex: number - deviceIdentifier: string - lastRegistrationTimestamp: number + deviceIndex: number; + deviceIdentifier: string; + lastRegistrationTimestamp: number; } export type RpcReturningMemberStatus = - | { type: 'unknown' } - | { type: 'newMember' } - | { type: 'returningMember' } + | { type: "unknown" } + | { type: "newMember" } + | { type: "returningMember" }; -export type RpcRoomId = string +export type RpcRoomId = string; export type RpcRoomListEntry = - | { kind: 'empty' } - | { kind: 'invalidated'; value: string } - | { kind: 'filled'; value: string } + | { kind: "empty" } + | { kind: "invalidated"; value: string } + | { kind: "filled"; value: string }; export interface RpcRoomMember { - userId: RpcUserId - displayName: string | null - avatarUrl: string | null - powerLevel: number - membership: RpcMatrixMembership + userId: RpcUserId; + displayName: string | null; + avatarUrl: string | null; + powerLevel: number; + membership: RpcMatrixMembership; } export type RpcRoomNotificationMode = - | 'allMessages' - | 'mentionsAndKeywordsOnly' - | 'mute' + | "allMessages" + | "mentionsAndKeywordsOnly" + | "mute"; -export type RpcRoomPowerLevelsEventContent = any +export type RpcRoomPowerLevelsEventContent = any; export interface RpcSignedLnurlMessage { - signature: string - pubkey: RpcPublicKey + signature: string; + pubkey: RpcPublicKey; } export interface RpcStabilityPoolAccountInfo { - idleBalance: RpcAmount - stagedSeeks: Array - stagedCancellation: number | null - lockedSeeks: Array - timestamp: number - isFetchedFromServer: boolean + idleBalance: RpcAmount; + stagedSeeks: Array; + stagedCancellation: number | null; + lockedSeeks: Array; + timestamp: number; + isFetchedFromServer: boolean; } export interface RpcStabilityPoolConfig { - kind: string - min_allowed_seek: RpcAmount - max_allowed_provide_fee_rate_ppb: number | null - min_allowed_cancellation_bps: number | null - cycle_duration: RpcDuration + kind: string; + min_allowed_seek: RpcAmount; + max_allowed_provide_fee_rate_ppb: number | null; + min_allowed_cancellation_bps: number | null; + cycle_duration: RpcDuration; } export type RpcStabilityPoolTransactionState = - | { type: 'pendingDeposit' } - | { - type: 'completeDeposit' - initial_amount_cents: number - fees_paid_so_far: RpcAmount - } - | { type: 'pendingWithdrawal'; estimated_withdrawal_cents: number } - | { type: 'completeWithdrawal'; estimated_withdrawal_cents: number } + | { type: "pendingDeposit" } + | { + type: "completeDeposit"; + initial_amount_cents: number; + fees_paid_so_far: RpcAmount; + } + | { type: "pendingWithdrawal"; estimated_withdrawal_cents: number } + | { type: "completeWithdrawal"; estimated_withdrawal_cents: number }; -export type RpcSyncIndicator = 'hide' | 'show' +export type RpcSyncIndicator = "hide" | "show"; export type RpcTimelineEventSendState = - | { kind: 'notSentYet' } - | { kind: 'sendingFailed'; error: string; is_recoverable: boolean } - | { kind: 'sent'; event_id: string } + | { kind: "notSentYet" } + | { kind: "sendingFailed"; error: string; is_recoverable: boolean } + | { kind: "sent"; event_id: string }; export type RpcTimelineItem = - | { kind: 'event'; value: RpcTimelineItemEvent } - | { kind: 'dayDivider'; value: number } - | { kind: 'readMarker' } - | { kind: 'unknown' } + | { kind: "event"; value: RpcTimelineItemEvent } + | { kind: "dayDivider"; value: number } + | { kind: "readMarker" } + | { kind: "unknown" }; export type RpcTimelineItemContent = - | { kind: 'message'; value: any } - | { kind: 'json'; value: any } - | { kind: 'redactedMessage' } - | { kind: 'unknown' } + | { kind: "message"; value: any } + | { kind: "json"; value: any } + | { kind: "redactedMessage" } + | { kind: "unknown" }; export interface RpcTimelineItemEvent { - id: string - txnId: string | null - eventId: string | null - content: RpcTimelineItemContent - localEcho: boolean - timestamp: number - sender: string - sendState: RpcTimelineEventSendState | null + id: string; + txnId: string | null; + eventId: string | null; + content: RpcTimelineItemContent; + localEcho: boolean; + timestamp: number; + sender: string; + sendState: RpcTimelineEventSendState | null; } export interface RpcTransaction { - id: string - createdAt: number - amount: RpcAmount - fediFeeStatus: RpcOperationFediFeeStatus | null - direction: RpcTransactionDirection - notes: string - onchainState: RpcOnchainState | null - bitcoin: RpcBitcoinDetails | null - lnState: RpcLnState | null - lightning: RpcLightningDetails | null - oobState: RpcOOBState | null - onchainWithdrawalDetails: WithdrawalDetails | null - stabilityPoolState: RpcStabilityPoolTransactionState | null + id: string; + createdAt: number; + amount: RpcAmount; + fediFeeStatus: RpcOperationFediFeeStatus | null; + direction: RpcTransactionDirection; + notes: string; + onchainState: RpcOnchainState | null; + bitcoin: RpcBitcoinDetails | null; + lnState: RpcLnState | null; + lightning: RpcLightningDetails | null; + oobState: RpcOOBState | null; + onchainWithdrawalDetails: WithdrawalDetails | null; + stabilityPoolState: RpcStabilityPoolTransactionState | null; + txDateFiatInfo: TransactionDateFiatInfo | null; } -export type RpcTransactionDirection = 'receive' | 'send' - -export type RpcUserId = string +export type RpcTransactionDirection = "receive" | "send"; -export interface RpcXmppCredentials { - password: string - keypairSeed: string - username: string | null -} +export type RpcUserId = string; export type SerdeVectorDiff = - | { kind: 'append'; values: T[] } - | { kind: 'clear' } - | { kind: 'pushFront'; value: T } - | { kind: 'pushBack'; value: T } - | { kind: 'popFront' } - | { kind: 'popBack' } - | { kind: 'insert'; index: number; value: T } - | { kind: 'set'; index: number; value: T } - | { kind: 'remove'; index: number } - | { kind: 'truncate'; length: number } - | { kind: 'reset'; values: T[] } + | { kind: "append"; values: T[] } + | { kind: "clear" } + | { kind: "pushFront"; value: T } + | { kind: "pushBack"; value: T } + | { kind: "popFront" } + | { kind: "popBack" } + | { kind: "insert"; index: number; value: T } + | { kind: "set"; index: number; value: T } + | { kind: "remove"; index: number } + | { kind: "truncate"; length: number } + | { kind: "reset"; values: T[] }; export interface SocialRecoveryApproval { - guardianName: string - approved: boolean + guardianName: string; + approved: boolean; } export interface SocialRecoveryEvent { - approvals: Array - remaining: number + approvals: Array; + remaining: number; } export interface StabilityPoolDepositEvent { - federationId: RpcFederationId - operationId: RpcOperationId - state: StabilityPoolDepositState + federationId: RpcFederationId; + operationId: RpcOperationId; + state: StabilityPoolDepositState; } export type StabilityPoolDepositState = - | 'initiated' - | 'txAccepted' - | { txRejected: string } - | { primaryOutputError: string } - | 'success' + | "initiated" + | "txAccepted" + | { txRejected: string } + | { primaryOutputError: string } + | "success"; export interface StabilityPoolUnfilledDepositSweptEvent { - amount: RpcAmount + amount: RpcAmount; } export interface StabilityPoolWithdrawalEvent { - federationId: RpcFederationId - operationId: RpcOperationId - state: StabilityPoolWithdrawalState + federationId: RpcFederationId; + operationId: RpcOperationId; + state: StabilityPoolWithdrawalState; } export type StabilityPoolWithdrawalState = - | 'invalidOperationType' - | 'withdrawUnlockedInitiated' - | { txRejected: string } - | 'withdrawUnlockedAccepted' - | { primaryOutputError: string } - | 'success' - | { cancellationSubmissionFailure: string } - | 'cancellationInitiated' - | 'cancellationAccepted' - | { awaitCycleTurnoverError: string } - | { withdrawIdleSubmissionFailure: string } - | 'withdrawIdleInitiated' - | 'withdrawIdleAccepted' + | "invalidOperationType" + | "withdrawUnlockedInitiated" + | { txRejected: string } + | "withdrawUnlockedAccepted" + | { primaryOutputError: string } + | "success" + | { cancellationSubmissionFailure: string } + | "cancellationInitiated" + | "cancellationAccepted" + | { awaitCycleTurnoverError: string } + | { withdrawIdleSubmissionFailure: string } + | "withdrawIdleInitiated" + | "withdrawIdleAccepted"; + +export interface TransactionDateFiatInfo { + fiatCode: string; + fiatValueHundredths: number; +} export interface TransactionEvent { - federationId: RpcFederationId - transaction: RpcTransaction + federationId: RpcFederationId; + transaction: RpcTransaction; } -export type TsAny = any +export type TsAny = any; -export type UserProfile = any +export type UserProfile = any; export interface WithdrawalDetails { - address: string - txid: string - fee: RpcAmount - feeRate: number + address: string; + txid: string; + fee: RpcAmount; + feeRate: number; } diff --git a/ui/common/types/chat.ts b/ui/common/types/chat.ts index e9205e4..6948e5b 100644 --- a/ui/common/types/chat.ts +++ b/ui/common/types/chat.ts @@ -1,6 +1,5 @@ import type { Status } from '@xmpp/connection' -import { RpcResponse } from './bindings' import type { Invoice } from './fedimint' import type { MSats } from './units' @@ -124,9 +123,6 @@ export interface ChatGroupSettings { /** @deprecated XMPP legacy code */ export type XmppClientStatus = Status -/** @deprecated XMPP legacy code */ -export type XmppCredentials = RpcResponse<'xmppCredentials'> - /** @deprecated XMPP legacy code */ export interface XmppConnectionOptions { // The domain where the Prosody chat server is hosted diff --git a/ui/common/types/fedimint.ts b/ui/common/types/fedimint.ts index c6158c7..cb48798 100644 --- a/ui/common/types/fedimint.ts +++ b/ui/common/types/fedimint.ts @@ -9,6 +9,7 @@ import { RecoveryProgressEvent, RpcCommunity, RpcFederation, + RpcFederationMaybeLoading, RpcFederationPreview, RpcInvoice, RpcLightningGateway, @@ -147,18 +148,51 @@ export enum Network { regtest = 'regtest', } -export type Federation = Omit & { +/** + * Connection Status of a federation's guardians + * + * - online: all guardians are online + * - unstable: At least one guardian is offline, but + * consensus is still met + * - offline: Consensus is not met + */ +export type FederationStatus = 'online' | 'unstable' | 'offline' + +export interface LoadingFederation { + id: string + meta?: never + readonly init_state: 'loading' + readonly hasWallet: true +} +export interface FederationInitFailure { + id: string + error: string + meta?: never + readonly init_state: 'failed' + readonly hasWallet: true +} + +export type LoadedFederation = Omit & { meta: ClientConfigMetadata - network: Network + network: Network | undefined + status: FederationStatus + readonly init_state: 'ready' readonly hasWallet: true } +export type Federation = + | LoadingFederation + | FederationInitFailure + | LoadedFederation + export type Community = Omit & { id: Federation['id'] meta: ClientConfigMetadata + status: FederationStatus // Added for compatibility with Mods readonly network: undefined readonly hasWallet: false + readonly init_state: 'ready' } export type RpcCommunityPreview = RpcCommunity @@ -170,7 +204,9 @@ export type JoinPreview = FederationPreview | CommunityPreview // Check if hasWallet is true to determine if it's a wallet type or community export type FederationListItem = Federation | Community -export type PublicFederation = Pick +export type LoadedFederationListItem = LoadedFederation | Community + +export type PublicFederation = Pick export type SeedWords = RpcResponse<'getMnemonic'> @@ -197,7 +233,7 @@ export type FederationPreview = Omit & { * Mocked-out social backup and recovery events */ -export type FederationEvent = Federation +export type FederationEvent = RpcFederationMaybeLoading export interface TransactionEvent { federationId: string diff --git a/ui/common/types/matrix.ts b/ui/common/types/matrix.ts index df40e7d..2545f0d 100644 --- a/ui/common/types/matrix.ts +++ b/ui/common/types/matrix.ts @@ -36,6 +36,7 @@ export interface MatrixRoomPreview { avatarUrl?: string body: string timestamp: number + isDeleted: boolean } export type MatrixGroupPreview = { info: MatrixRoom diff --git a/ui/common/utils/FederationUtils.ts b/ui/common/utils/FederationUtils.ts index 0e83919..d0617c5 100644 --- a/ui/common/utils/FederationUtils.ts +++ b/ui/common/utils/FederationUtils.ts @@ -5,10 +5,13 @@ import { DEFAULT_FEDIMODS } from '@fedi/common/constants/fedimods' import { XMPP_RESOURCE } from '../constants/xmpp' import { ClientConfigMetadata, + Community, Federation, FederationListItem, + FederationStatus, FediMod, JoinPreview, + LoadedFederation, MSats, Network, PublicFederation, @@ -16,7 +19,7 @@ import { SupportedMetaFields, XmppConnectionOptions, } from '../types' -import { RpcCommunity } from '../types/bindings' +import { RpcCommunity, RpcFederation } from '../types/bindings' import { FedimintBridge } from './fedimint' import { makeLog } from './log' @@ -48,7 +51,7 @@ export const getMetaUrl = (meta: ClientConfigMetadata): string | undefined => { } } -type ExternalMetaJson = Record +type ExternalMetaJson = Record /** * Given a URL, attempt to fetch external metadata. Returns a promise @@ -117,24 +120,31 @@ const fetchExternalMetadata = async ( * Runs `fetchFederationExternalMetadata` on a list of federations and assembles * the results as a map of federation id -> meta. Optional callback is called with * (federationId, meta). + * + * Note this currently seems very overcomplicated since it doesn't + * need to handle multiple communities with external meta urls anymore + * and it wasn't worth refactoring to remove the extra complexity. + * + * TODO: Remove this function entirely when the bridge can provide us with the global + * community meta and we don't have to fetch it ourselves */ export const fetchFederationsExternalMetadata = async ( - federations: Pick[], + communitiesToFetch: Pick[], onBackgroundSuccess?: ( - federationId: FederationListItem['id'], - meta: FederationListItem['meta'], + federationId: Community['id'], + meta: Community['meta'], ) => void, ): Promise => { // Given an external meta, return a list of federation id -> meta for all matching federations - const getFederationMetaEntries = (externalMeta: ExternalMetaJson) => { + const getMetaEntries = (externalMeta: ExternalMetaJson) => { const entries: [ FederationListItem['id'], FederationListItem['meta'], ][] = [] - for (const federation of federations) { - const fedMeta = externalMeta[federation.id] - if (fedMeta) { - entries.push([federation.id, fedMeta]) + for (const community of communitiesToFetch) { + const communityMeta = externalMeta[community.id] + if (communityMeta) { + entries.push([community.id, communityMeta]) } } return entries @@ -143,30 +153,23 @@ export const fetchFederationsExternalMetadata = async ( // When results come in in the background, hit the callback for relevant federations const handleBackgroundSuccess = onBackgroundSuccess ? (externalMeta: ExternalMetaJson) => { - const entries = getFederationMetaEntries(externalMeta) - entries.forEach(([id, meta]) => onBackgroundSuccess(id, meta)) + const entries = getMetaEntries(externalMeta) + entries.forEach( + ([id, meta]) => meta && onBackgroundSuccess(id, meta), + ) } : undefined - const communitiesMeta = federations - .filter(f => !f.hasWallet) - .reduce((prev, community) => { - if (!community || !community.id) return prev - prev[community.id] = community.meta - handleBackgroundSuccess && handleBackgroundSuccess(prev) - return prev - }, {}) - // Collect & deduplicate external meta URLs - const externalUrls = federations - .map(f => getMetaUrl(f.meta)) + const externalUrls = communitiesToFetch + .map(c => getMetaUrl(c.meta)) .filter((url, idx, arr): url is string => Boolean(url && arr.indexOf(url) === idx), ) // Assemble all the promises and return the first pass of results. If they // provided onBackgroundSuccess, we'll call those as they come in. - const federationsMeta = await Promise.all([ + const communitiesMeta = await Promise.all([ ...externalUrls.map(url => { return fetchExternalMetadata(url, res => { return handleBackgroundSuccess && handleBackgroundSuccess(res) @@ -175,14 +178,14 @@ export const fetchFederationsExternalMetadata = async ( ]).then(results => { return results.reduce((prev, extMeta) => { if (!extMeta) return prev - const entries = getFederationMetaEntries(extMeta) + const entries = getMetaEntries(extMeta) for (const entry of entries) { prev[entry[0]] = entry[1] } return prev }, {}) }) - return { ...communitiesMeta, ...federationsMeta } + return communitiesMeta } /** @@ -351,7 +354,7 @@ export const shouldShowJoinFederation = (metadata: ClientConfigMetadata) => { ) } -export const shouldShowSocialRecovery = (federation: FederationListItem) => { +export const shouldShowSocialRecovery = (federation: LoadedFederation) => { // Social recovery not supported on v0 federations if (federation.version === 0) { return false @@ -397,7 +400,7 @@ export const shouldEnableStabilityPool = (metadata: ClientConfigMetadata) => { } // TODO: Determine if no-wallet communities breaks this -export function supportsSingleSeed(federation: FederationListItem) { +export function supportsSingleSeed(federation: LoadedFederation) { return federation.version >= 2 } @@ -508,8 +511,27 @@ export const getFederationTosUrl = (metadata: ClientConfigMetadata) => { return getMetaField(SupportedMetaFields.tos_url, metadata) } -export const getFederationName = (metadata: ClientConfigMetadata) => { - return getMetaField(SupportedMetaFields.federation_name, metadata) +export const getFederationName = ( + federation: FederationListItem | JoinPreview, +): string => { + let name = '' + if ('meta' in federation && federation.meta) { + name = + getMetaField( + SupportedMetaFields.federation_name, + federation.meta, + ) || '' + } + // if no name is found in meta, try the name directly on the federation + if ( + !name && + 'name' in federation && + federation.name && + typeof federation.name === 'string' + ) { + name = federation.name || '' + } + return name } export const getFederationWelcomeMessage = (metadata: ClientConfigMetadata) => { @@ -524,9 +546,7 @@ export const getFederationIconUrl = (metadata: ClientConfigMetadata) => { return getMetaField(SupportedMetaFields.federation_icon_url, metadata) } -export const getIsFederationSupported = ( - federation: Pick, -) => { +export const getIsFederationSupported = (federation: JoinPreview) => { if ( federation.hasWallet && (federation.version === 0 || federation.version === 1) @@ -544,7 +564,7 @@ async function getFederationPreview( inviteCode: string, fedimint: FedimintBridge, ): Promise { - let externalMeta = {} + let externalMeta: ClientConfigMetadata = {} // The federation preview may have an external URL where the meta // fields need to be fetched from... otherwise we won't know about chat // servers after joining which will break onboarding @@ -574,8 +594,8 @@ async function getFederationPreview( return { ...preview, name: - getFederationName(externalMeta) || - getFederationName(preview.meta) || + externalMeta.federation_name || + preview.meta.federation_name || preview.name, meta: { ...preview.meta, @@ -585,12 +605,31 @@ async function getFederationPreview( } } +export const coerceLoadedFederation = ( + federation: { init_state: 'ready' } & RpcFederation, +): LoadedFederation => { + /* + * Client-side network failure will cause getFederationStatus to + * hang and timeout after 10 seconds so we assume online by default + * and instead fetch the status in the background. This should mean + * a smoother UX since we avoid flickering indicators + */ + return { + ...federation, + status: 'online', + network: federation.network as Network, + hasWallet: true, + } +} + export const coerceFederationListItem = ( community: RpcCommunity, ): FederationListItem => { return { hasWallet: false as const, network: undefined, + status: 'online', + init_state: 'ready', // We cannot really guarantee unique IDs in the body since community creators // have free reign to modify the JSON as they see fit. So to prevent erroneous @@ -611,7 +650,9 @@ export const coerceJoinPreview = (preview: RpcCommunity): JoinPreview => { hasWallet: false as const, id: inviteCode, inviteCode, + status: 'online', network: undefined, + init_state: 'ready', ...rest, } } @@ -647,10 +688,14 @@ export const joinFromInvite = async ( code, recoverFromScratch, ) + const status = await getFederationStatus(fedimint, federation.id) + // TODO: Show a warning to the user depending on the status return { ...federation, hasWallet: true, network: network as Network, + status, + init_state: 'ready', } } else { // community @@ -675,3 +720,25 @@ export const previewInvite = async ( return coerceJoinPreview(preview) } } + +export const getFederationStatus = async ( + fedimint: FedimintBridge, + federationId: FederationListItem['id'], +): Promise => { + const guardianStatuses = await fedimint.guardianStatus(federationId) + const offlineGuardians = guardianStatuses.filter(status => { + // Guardian is online + if ('online' in status) return false + // TODO: handle other unusual states we may see here to qualify connection health? + else return true + }) + if (offlineGuardians.length === 0) { + return 'online' + } + // A federation can achieve consensus if 3f + 1 guardians are online, + // where f is the number of "faulty" guardians. + if (3 * offlineGuardians.length + 1 <= guardianStatuses.length) { + return 'unstable' + } + return 'offline' +} diff --git a/ui/common/utils/MatrixChatClient.ts b/ui/common/utils/MatrixChatClient.ts index a54c62a..8c520a4 100644 --- a/ui/common/utils/MatrixChatClient.ts +++ b/ui/common/utils/MatrixChatClient.ts @@ -835,18 +835,24 @@ export class MatrixChatClient { const directUserId = room.base_info.dm_targets?.[0] let preview: MatrixRoom['preview'] if (room.latest_event) { + const { event, sender_profile } = room.latest_event + const isDeleted = !!event.event.unsigned?.redacted_because + // Try to use the redaction timestamp if found, fallback to event timestamp + const timestamp = isDeleted + ? event.event.unsigned?.redacted_because.origin_server_ts || + event.event.origin_server_ts + : event.event.origin_server_ts preview = { - eventId: room.latest_event.event.event.event_id, - senderId: room.latest_event.sender_profile.Original.content.id, + eventId: event.event.event_id, + senderId: sender_profile.Original.content.id, displayName: this.ensureDisplayName( - room.latest_event.sender_profile.Original.content - .displayname, + sender_profile.Original.content.displayname, ), - avatarUrl: - room.latest_event.sender_profile.Original.content - .avatar_url, - body: room.latest_event.event.event.content.body, - timestamp: room.latest_event.event.event.origin_server_ts, + avatarUrl: sender_profile.Original.content.avatar_url, + body: event.event.content.body, + // Deleted/redacted messages have this in the unsigned field + isDeleted, + timestamp, } } @@ -938,13 +944,6 @@ export class MatrixChatClient { item.value.content.value.type !== 'm.room.message' ) return null - // ignore deleted messages - if ( - item.value.content.kind === 'json' && - item.value.content.value && - 'redacted_because' in item.value.content.value - ) - return null // Map the status to an enum, include the error if it failed let status: MatrixEventStatus @@ -968,16 +967,50 @@ export class MatrixChatClient { } } + const eventContent = item.value.content + let content: MatrixEventContent | undefined + if (eventContent.kind === 'json') { + content = eventContent.value.content + } else { + content = item.value.content.value + } + /* + * We detect and handle redacted messages in two ways: + * 1) when a message is deleted in real-time, a timeline update fires a redactedMessage event kind + * 2) when we load a timeline and find a deleted message, the event content contains the unsigned.redacted_because fields + */ + if (eventContent.kind === 'redactedMessage') { + content = { + msgtype: 'xyz.fedi.deleted', + body: '', + redacts: item.value.eventId, + } + } else if ( + 'unsigned' in eventContent.value && + 'redacted_because' in eventContent.value.unsigned + ) { + content = { + msgtype: 'xyz.fedi.deleted', + body: '', + ...eventContent.value.unsigned.redacted_because.content, + } + } + + if (!content) return null + + if (roomId === '!wtRHgcDfnuvWGdHBTx:m1.8fa.in') { + log.debug( + 'serializeTimelineItem item.value.content', + JSON.stringify(content), + ) + } + // const content = return { roomId, id: item.value.id, txnId: item.value.txnId, eventId: item.value.eventId, - content: formatMatrixEventContent( - item.value.content.kind === 'json' - ? item.value.content.value.content - : item.value.content.value, - ), + content: formatMatrixEventContent(content), timestamp: item.value.timestamp, senderId: item.value.sender, status, diff --git a/ui/common/utils/csv.ts b/ui/common/utils/csv.ts index 44aa4e5..0533e08 100644 --- a/ui/common/utils/csv.ts +++ b/ui/common/utils/csv.ts @@ -30,7 +30,7 @@ export function makeTransactionHistoryCSV( } else if (txn.onchainState) { const { type } = txn.onchainState - return type === 'succeeded' || type === 'claimed' + return type === 'succeeded' || type === 'claimed' || type === 'confirmed' } else if (txn.oobState) { const { type } = txn.oobState diff --git a/ui/common/utils/fedimint.ts b/ui/common/utils/fedimint.ts index 0a45e2b..c60f4db 100644 --- a/ui/common/utils/fedimint.ts +++ b/ui/common/utils/fedimint.ts @@ -12,7 +12,9 @@ import { GuardianStatus, RpcAmount, RpcFeeDetails, + RpcMediaSource, RpcPayAddressResponse, + RpcRoomId, RpcStabilityPoolAccountInfo, } from '../types/bindings' import amountUtils from './AmountUtils' @@ -232,14 +234,6 @@ export class FedimintBridge { }) } - async getXmppCredentials(federationId: string) { - return this.rpcTyped('xmppCredentials', { federationId }) - } - - async backupXmppUsername(username: string, federationId: string) { - return this.rpcTyped('backupXmppUsername', { username, federationId }) - } - async listGateways(federationId: string) { return this.rpcTyped('listGateways', { federationId }) } @@ -361,6 +355,34 @@ export class FedimintBridge { /*** MATRIX ***/ + async matrixSendAttachment(args: bindings.RpcPayload<'matrixSendAttachment'>) { + return this.rpcTyped('matrixSendAttachment', args) + } + + async matrixEditMessage(roomId: RpcRoomId, eventId: string, newContent: string) { + return this.rpcTyped('matrixEditMessage', { roomId, eventId, newContent }) + } + + async matrixDeleteMessage(roomId: RpcRoomId, eventId: string, reason: string | null) { + return this.rpcTyped('matrixDeleteMessage', { roomId, eventId, reason }) + } + + async matrixDownloadFile(path: string, mediaSource: RpcMediaSource) { + return this.rpcTyped('matrixDownloadFile', { path, mediaSource }) + } + + async matrixStartPoll(roomId: RpcRoomId, question: string, answers: Array) { + return this.rpcTyped('matrixStartPoll', { roomId, question, answers }) + } + + async matrixEndPoll(roomId: RpcRoomId, pollStartId: string) { + return this.rpcTyped('matrixEndPoll', { roomId, pollStartId }) + } + + async matrixRespondToPoll(roomId: RpcRoomId, pollStartId: string, selections: Array) { + return this.rpcTyped('matrixRespondToPoll', { roomId, pollStartId, selections }) + } + async matrixInit() { return this.rpcTyped('matrixInit', {}) } diff --git a/ui/common/utils/log.ts b/ui/common/utils/log.ts index b11da42..90ea2f8 100644 --- a/ui/common/utils/log.ts +++ b/ui/common/utils/log.ts @@ -1,4 +1,5 @@ import { StorageApi } from '../types' +import { isDev } from './environment' type LogLevel = 'debug' | 'info' | 'warn' | 'error' @@ -118,9 +119,11 @@ function innerLog( } cachedLogs.push(logItem) - // eslint-disable-next-line no-console - const consoleFn = console[level] - consoleFn(context ? `[${context}] ${message}` : message, ...extra) + if (isDev()) { + // eslint-disable-next-line no-console + const consoleFn = console[level] + consoleFn(context ? `[${context}] ${message}` : message, ...extra) + } // Force a save if we have more than 20 logs in the cache. Otherwise save // after a brief delay. diff --git a/ui/common/utils/matrix.ts b/ui/common/utils/matrix.ts index aaaeab1..2cd9ba1 100644 --- a/ui/common/utils/matrix.ts +++ b/ui/common/utils/matrix.ts @@ -7,7 +7,7 @@ import EncryptionUtils from '@fedi/common/utils/EncryptionUtils' import { GLOBAL_MATRIX_SERVER } from '../constants/matrix' import { FormattedAmounts } from '../hooks/amount' import { - Federation, + LoadedFederation, MSats, MatrixEvent, MatrixGroupPreview, @@ -41,6 +41,17 @@ export const mxcUrlToHttpUrl = ( return url.toString() } +const fileSchema = z + .object({ + hashes: z.object({ + sha256: z.string(), + }), + url: z.string().url(), + v: z.literal('v2'), + }) + // Don't strip off additional decryption keys from the file object + .passthrough() + const contentSchemas = { /* Matrix standard events, not an exhaustive list */ 'm.text': z.object({ @@ -60,18 +71,27 @@ const contentSchemas = { w: z.number(), h: z.number(), }), + file: fileSchema, }), 'm.video': z.object({ msgtype: z.literal('m.video'), body: z.string(), - url: z.string(), info: z.object({ mimetype: z.string(), size: z.number(), w: z.number(), h: z.number(), - duration: z.number(), }), + file: fileSchema, + }), + 'm.file': z.object({ + msgtype: z.literal('m.file'), + body: z.string(), + info: z.object({ + mimetype: z.string(), + size: z.number(), + }), + file: fileSchema, }), 'm.emote': z.object({ msgtype: z.literal('m.emote'), @@ -132,6 +152,12 @@ const contentSchemas = { // invites enabled, and allow people to join to accept ecash? inviteCode: z.string().optional(), }), + 'xyz.fedi.deleted': z.object({ + msgtype: z.literal('xyz.fedi.deleted'), + body: z.string(), + redacts: z.string(), + reason: z.string().optional(), + }), } interface MatrixEventUnknownContent { @@ -140,6 +166,9 @@ interface MatrixEventUnknownContent { originalContent: unknown } +export type MatrixEventContentType = + z.infer<(typeof contentSchemas)[T]> + export type MatrixEventContent = | z.infer<(typeof contentSchemas)[keyof typeof contentSchemas]> | MatrixEventUnknownContent @@ -238,6 +267,8 @@ export function makeChatFromPreview(preview: MatrixGroupPreview) { // TODO: get this from members list if we have them displayName: previewContent?.senderId || '', senderId: previewContent?.senderId || '', + // TODO: handle if deleted messages are returned in public group previews + isDeleted: false, } } @@ -344,7 +375,7 @@ export function isPaymentEvent( export function getReceivablePaymentEvents( timeline: MatrixTimelineItem[], myId: string, - myFederations: Federation[], + myFederations: LoadedFederation[], ) { const latestPayments: Record = {} timeline.forEach(item => { @@ -469,3 +500,25 @@ export function shouldShowUnreadIndicator( if (isMarkedUnread) return true return false } + +export function isDeletedEvent( + event: MatrixEvent, +): event is MatrixEvent> { + return event.content.msgtype === 'xyz.fedi.deleted' +} + +export function isTextEvent(event: MatrixEvent): event is MatrixEvent> { + return event.content.msgtype === 'm.text' +} + +export function isImageEvent(event: MatrixEvent): event is MatrixEvent> { + return event.content.msgtype === 'm.image' +} + +export function isFileEvent(event: MatrixEvent): event is MatrixEvent> { + return event.content.msgtype === 'm.file' +} + +export function isVideoEvent(event: MatrixEvent): event is MatrixEvent> { + return event.content.msgtype === 'm.video' +} diff --git a/ui/common/utils/media.ts b/ui/common/utils/media.ts new file mode 100644 index 0000000..294ddd1 --- /dev/null +++ b/ui/common/utils/media.ts @@ -0,0 +1,46 @@ +/** + * Formats a file size in bytes to a human-readable string. + */ +export const formatFileSize = (bytes: number) => { + if (bytes < 1024) return `${bytes} b` + else if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} kb` + else return `${(bytes / 1024 / 1024).toFixed(1)} mb` +} + +/** + * Scales an image to fit within a given maxWidth and maxHeight while maintaining its aspect ratio. + */ +export const scaleAttachment = ( + originalWidth: number, + originalHeight: number, + maxWidth: number, + maxHeight: number, +) => { + let width = originalWidth + let height = originalHeight + + // Calculate scaling factors + const widthScale = maxWidth / originalWidth + const heightScale = maxHeight / originalHeight + + // Check which dimension exceeds the limit and scale accordingly + if (width > maxWidth && height > maxHeight) { + if (widthScale < heightScale) { + // Limit by width + width = maxWidth + height *= widthScale // Apply scale to maintain aspect ratio + } else { + // Limit by height + height = maxHeight + width *= heightScale // Apply scale to maintain aspect ratio + } + } else if (width > maxWidth) { + width = maxWidth + height *= widthScale + } else if (height > maxHeight) { + height = maxHeight + width *= heightScale + } + + return { width, height } +} diff --git a/ui/common/utils/redux.ts b/ui/common/utils/redux.ts index b749bb1..48a620c 100644 --- a/ui/common/utils/redux.ts +++ b/ui/common/utils/redux.ts @@ -9,6 +9,14 @@ import isEqual from 'lodash/isEqual' export function upsertListItem( list: T[] | null | undefined, item: T, + /** + * If the item has nested fields, pass in the keys of the nested fields to + * merge so we don't overwrite the entire object. + * needed for federation.meta for example + * You should NOT pass in fields that are not objects like federation.init_state + * for example would be useless + */ + nestedFields?: (keyof T)[], /** * A sorting function to call when a new item is added or existing item is * modified. Must be in ascending order so that the newest item is at the @@ -23,7 +31,15 @@ export function upsertListItem( let newList = list.map(oldItem => { if (oldItem.id !== item.id) return oldItem addToEnd = false - const updatedEntity = { ...oldItem, ...item } + const updatedEntity: T = { ...oldItem, ...item } + if (nestedFields) { + nestedFields.map(field => { + updatedEntity[field] = { + ...oldItem[field], + ...item[field], + } + }) + } wasEqual = isEqual(oldItem, updatedEntity) return updatedEntity }) diff --git a/ui/common/utils/wallet.ts b/ui/common/utils/wallet.ts index da5670f..905af3b 100644 --- a/ui/common/utils/wallet.ts +++ b/ui/common/utils/wallet.ts @@ -48,10 +48,8 @@ export const makeTxnDetailStatusText = ( switch (txn.lnState?.type) { case 'waitingForRefund': return t('feature.send.waiting-for-refund') - case 'refunded': - return t('words.refund') case 'canceled': - return t('words.expired') + case 'refunded': case 'failed': return t('words.failed') default: @@ -101,6 +99,8 @@ export const makeTxnDetailStatusText = ( return t('words.pending') case 'waitingForConfirmation': return t('words.seen') + case 'confirmed': + return t('words.seen') case 'claimed': return t('words.complete') default: @@ -269,10 +269,13 @@ export const makeTxnStatusText = (t: TFunction, txn: Transaction): string => { switch (txn.lnState?.type) { case 'waitingForRefund': return t('phrases.refund-pending') - case 'refunded': - return t('words.refund') + case 'success': + return t('phrases.sent-bitcoin') + case 'created': + case 'funded': + return t('words.pending') case 'canceled': - return t('words.expired') + case 'refunded': case 'failed': return t('words.failed') default: @@ -459,6 +462,16 @@ export const makeTxnDetailItems = ( copyable: true, truncated: true, }) + + if (txn.lnState?.type === 'success' && txn.direction === 'send') { + items.push({ + label: t('words.preimage'), + value: txn.lnState.preimage, + copiedMessage: t('phrases.copied-to-clipboard'), + copyable: true, + truncated: true, + }) + } } if (txn.bitcoin) { items.push({ diff --git a/ui/docs/meta_fields/README.md b/ui/docs/meta_fields/README.md index 32a0ed3..b6ca6b8 100644 --- a/ui/docs/meta_fields/README.md +++ b/ui/docs/meta_fields/README.md @@ -20,6 +20,7 @@ The following meta fields are interpretable by the Fedi app (note the `fedi:` pr * [`fedi:stability_pool_disabled`](stability_pool_disabled.md): Boolean value that disables the stability pool features * [`fedi:max_invoice_msats`](max_invoice_msats.md): Number value in millisats that prevents users from generating invoices higher than the specified amount * [`fedi:max_balance_msats`](max_balance_msats.md): Number value in millisats that prevents users from having a balance higher than the specified amount +* [`fedi:max_stable_balance_msats`](max_stable_balance_msats.md): Number value in millisats that prevents users from having a stable balance higher than the specified amount * [`fedi:fedimods`](fedimods.md): Stringified JSON array of objects representing the default FediMods shown to users upon joining the federation * [`fedi:default_group_chats`](default_group_chats.md): Stringified JSON array of strings representing the IDs of any chat groups that all users will join automatically upon creating their username * [`fedi:welcome_message`](welcome_message.md): A message presented to users upon joining the federation diff --git a/ui/docs/meta_fields/max_stable_balance_msats.md b/ui/docs/meta_fields/max_stable_balance_msats.md new file mode 100644 index 0000000..7c6e5a8 --- /dev/null +++ b/ui/docs/meta_fields/max_stable_balance_msats.md @@ -0,0 +1,11 @@ +# `fedi:max_balance_msats` + +When set, users will be prevented from having a stable balance greater than the specified amount. + +## Structure + +Base 10 encoded (stringified) integer + +```json +"fedi:max_stable_balance_msats": "100000" // 100 sat max +``` diff --git a/ui/injections/package.json b/ui/injections/package.json index 0ee0067..d3afa36 100644 --- a/ui/injections/package.json +++ b/ui/injections/package.json @@ -18,7 +18,7 @@ "prettier": "^2.8.7", "ts-loader": "^9.4.4", "typescript": "^5.0.2", - "webpack": "^5.88.2", + "webpack": "^5.95.0", "webpack-cli": "^5.1.4" }, "dependencies": { diff --git a/ui/injections/src/injectables/fediInternal.ts b/ui/injections/src/injectables/fediInternal.ts index 5d871f2..378993d 100644 --- a/ui/injections/src/injectables/fediInternal.ts +++ b/ui/injections/src/injectables/fediInternal.ts @@ -1,7 +1,7 @@ import { EcashRequest, - FederationListItem, FediInternalVersion, + LoadedFederationListItem, MSats, SupportedCurrency, } from '@fedi/common/types' @@ -34,7 +34,7 @@ class InjectionFediProvider { } async getActiveFederation(): Promise< - Pick + Pick > { return this.sendMessage( InjectionMessageType.fedi_getActiveFederation, diff --git a/ui/injections/src/types.ts b/ui/injections/src/types.ts index e18d3da..5b76481 100644 --- a/ui/injections/src/types.ts +++ b/ui/injections/src/types.ts @@ -9,7 +9,7 @@ import type { import { EcashRequest, - FederationListItem, + LoadedFederationListItem, MSats, SupportedCurrency, } from '@fedi/common/types' @@ -88,7 +88,7 @@ export type InjectionMessageResponseMap = { } [InjectionMessageType.fedi_getActiveFederation]: { message: void - response: Pick + response: Pick } [InjectionMessageType.fedi_getCurrencyCode]: { message: void diff --git a/ui/native/Router.tsx b/ui/native/Router.tsx index 5ca86d8..8b0c09a 100644 --- a/ui/native/Router.tsx +++ b/ui/native/Router.tsx @@ -77,7 +77,10 @@ const Router = () => { + screenOptions={{ + swipeEnabled: isAppUnlocked, + freezeOnBlur: true, + }}> + + = ({ children }) => { // These all happen in parallel after bridge is initialized // Only throw (via unwrap) for refreshFederations. return Promise.all([ - dispatchRef.current(refreshFederations(fedimint)).unwrap(), dispatchRef.current(fetchSocialRecovery(fedimint)), dispatchRef.current(initializeNostrKeys({ fedimint })), // this happens when the user entered seed words but quit the app @@ -116,9 +115,18 @@ export const FediBridgeInitializer: React.FC = ({ children }) => { : []), ]) }) + .then(() => { + // wait until after the matrix client is started to refresh federations because + // the latest metadata may include new default chats that require + // matrix to fetch the room previews + return dispatchRef + .current(refreshFederations(fedimint)) + .unwrap() + }) .then(() => { setBridgeIsReady(true) - dispatchRef.current(previewDefaultGroupChats()) + // preview chats after matrix client has finished initializing + dispatchRef.current(previewAllDefaultChats()) }) .catch(err => { log.error( diff --git a/ui/native/components/feature/chat/ChatConnectionBadge.tsx b/ui/native/components/feature/chat/ChatConnectionBadge.tsx index dd17752..1fc801d 100644 --- a/ui/native/components/feature/chat/ChatConnectionBadge.tsx +++ b/ui/native/components/feature/chat/ChatConnectionBadge.tsx @@ -96,7 +96,15 @@ export const ChatConnectionBadge: React.FC = ({ const style = styles(theme) return ( - + @@ -117,7 +125,6 @@ const styles = (theme: Theme) => justifyContent: 'center', alignItems: 'center', pointerEvents: 'box-none', - zIndex: 3, elevation: 3, }, badge: { diff --git a/ui/native/components/feature/chat/ChatConversation.tsx b/ui/native/components/feature/chat/ChatConversation.tsx index bf8d9d8..63e56ad 100644 --- a/ui/native/components/feature/chat/ChatConversation.tsx +++ b/ui/native/components/feature/chat/ChatConversation.tsx @@ -39,11 +39,13 @@ type MessagesListProps = { type: ChatType id: string multiUserChat?: boolean + isPublic?: boolean } const ChatConversation: React.FC = ({ type, id, + isPublic = true, }: MessagesListProps) => { const { t } = useTranslation() const { theme } = useTheme() @@ -59,10 +61,6 @@ const ChatConversation: React.FC = ({ const [selectedUserId, setSelectedUserId] = useState(null) const dispatch = useAppDispatch() - const handleSelectMember = useCallback((userId: string) => { - setSelectedUserId(userId) - }, []) - // Room is empty if we're the only member const isAlone = useAppSelector(s => selectMatrixRoomMembersCount(s, id)) === 1 @@ -87,7 +85,7 @@ const ChatConversation: React.FC = ({ setHasPaginated(false) }, [events.length]) - const style = styles(theme) + const style = useMemo(() => styles(theme), [theme]) // Animate new message button in and out useEffect(() => { @@ -130,11 +128,11 @@ const ChatConversation: React.FC = ({ if (isPaginating || hasPaginated || isAtEnd) return setIsPaginating(true) setHasPaginated(true) - dispatch(paginateMatrixRoomTimeline({ roomId: id, limit: 30 })) + await dispatch(paginateMatrixRoomTimeline({ roomId: id, limit: 30 })) .unwrap() .then(({ end }) => setIsAtEnd(end)) .finally(() => setIsPaginating(false)) - }, [id, dispatch, isPaginating, hasPaginated, isAtEnd]) + }, [hasPaginated, id, isAtEnd, isPaginating, dispatch]) // Mark hasNewMessages as false when we scroll to the bottom, and keep a ref up to date const handleScroll = useCallback( @@ -158,11 +156,12 @@ const ChatConversation: React.FC = ({ roomId={id} collection={item} showUsernames={type === ChatType.group} - onSelect={handleSelectMember} + onSelect={setSelectedUserId} + isPublic={isPublic} /> ) }, - [handleSelectMember, id, type], + [id, type, isPublic], ) return ( @@ -192,9 +191,9 @@ const ChatConversation: React.FC = ({ ) } onScroll={handleScroll} - // adjust this for more/less aggressive loading - onEndReachedThreshold={1} inverted={events.length > 0} + // adjust this for more/less aggressive loading + onEndReachedThreshold={0.1} onEndReached={handlePaginate} refreshing={isPaginating} maintainVisibleContentPosition={{ diff --git a/ui/native/components/feature/chat/ChatConversationHeader.tsx b/ui/native/components/feature/chat/ChatConversationHeader.tsx index ef7602b..8bfc0fb 100644 --- a/ui/native/components/feature/chat/ChatConversationHeader.tsx +++ b/ui/native/components/feature/chat/ChatConversationHeader.tsx @@ -1,6 +1,6 @@ import { RouteProp, useNavigation, useRoute } from '@react-navigation/native' import { Text, Theme, useTheme } from '@rneui/themed' -import React from 'react' +import React, { useCallback, useMemo } from 'react' import { Pressable, StyleSheet, View } from 'react-native' import { @@ -32,25 +32,25 @@ const ChatConversationHeader: React.FC = () => { const preview = useAppSelector(s => selectGroupPreview(s, roomId)) const user = useAppSelector(s => selectMatrixUser(s, userId)) - const style = styles(theme) + const style = useMemo(() => styles(theme), [theme]) - let avatar: React.ReactNode - let name = '' - if (room) { - name = room?.name - avatar = - } else if (preview) { - name = preview?.info.name - avatar = - } else if (user) { - name = user?.displayName || user?.id - avatar = - } else if (displayName) { - const placeHolderUser = { id: '', displayName } - name = displayName - avatar = - } else { - avatar = ( + const name = useMemo(() => { + if (room) return room?.name + else if (preview) return preview?.info.name + else if (user) return user?.displayName || user?.id + return displayName || '' + }, [displayName, preview, room, user]) + + const avatar = useMemo(() => { + if (room) return + else if (preview) + return + else if (user) return + else if (displayName) { + const placeHolderUser = { id: '', displayName } + return + } + return ( { } /> ) - } + }, [displayName, name, preview, room, theme, user]) - return ( - <> -
- navigation.dispatch(resetToChatsScreen()) - } - containerStyle={style.container} - centerContainerStyle={style.headerCenterContainer} - headerCenter={ - { - // make sure we have joined room and its not just a preview to show admin settings - if (room) { - navigation.navigate('RoomSettings', { roomId }) + const handleBackButtonPress = useCallback(() => { + navigation.dispatch(resetToChatsScreen()) + }, [navigation]) + + const HeaderCenter = useMemo(() => { + return ( + { + // make sure we have joined room and its not just a preview to show admin settings + if (room) { + navigation.navigate('RoomSettings', { roomId }) + } + }}> + <> + {avatar} + + - {avatar} - + style={style.memberText}> + {name} + + {room?.directUserId && ( - {name} + style={style.shortIdText}> + {getUserSuffix(room.directUserId)} - {room?.directUserId && ( - - {getUserSuffix(room.directUserId)} - - )} - - - } + )} + + + + ) + }, [avatar, name, navigation, room, roomId, theme, style]) + + return ( + <> +
diff --git a/ui/native/components/feature/chat/ChatDeletedEvent.tsx b/ui/native/components/feature/chat/ChatDeletedEvent.tsx new file mode 100644 index 0000000..ce33ca7 --- /dev/null +++ b/ui/native/components/feature/chat/ChatDeletedEvent.tsx @@ -0,0 +1,56 @@ +import { selectMatrixAuth } from '@fedi/common/redux' +import { MatrixEvent } from '@fedi/common/types' +import { MatrixEventContentType } from '@fedi/common/utils/matrix' +import { Text, Theme, useTheme } from '@rneui/themed' +import { useTranslation } from 'react-i18next' +import { Pressable, StyleSheet } from 'react-native' +import { useAppSelector } from '../../../state/hooks' +import { OptionalGradient } from '../../ui/OptionalGradient' +import { bubbleGradient } from './ChatEvent' + +type Props = { + event: MatrixEvent> +} + +const ChatDeletedEvent: React.FC = ({ event }) => { + const matrixAuth = useAppSelector(selectMatrixAuth) + const { theme } = useTheme() + const { t } = useTranslation() + const style = styles(theme) + + const isMe = event.senderId === matrixAuth?.userId + + return ( + + + + {t('feature.chat.message-deleted')} + + + + ) +} + +const styles = (theme: Theme) => + StyleSheet.create({ + bubbleInner: { + padding: 10, + opacity: 0.8, + }, + greyBubble: { + backgroundColor: theme.colors.extraLightGrey, + opacity: 0.4, + }, + text: { + color: theme.colors.grey, + fontStyle: 'italic', + }, + outgoingText: { + color: theme.colors.grey, + fontStyle: 'italic', + }, + }) + +export default ChatDeletedEvent diff --git a/ui/native/components/feature/chat/ChatEvent.tsx b/ui/native/components/feature/chat/ChatEvent.tsx index 9faeccd..c00b51a 100644 --- a/ui/native/components/feature/chat/ChatEvent.tsx +++ b/ui/native/components/feature/chat/ChatEvent.tsx @@ -1,40 +1,52 @@ import { Theme, useTheme } from '@rneui/themed' import React from 'react' import { StyleProp, StyleSheet, TextStyle, View, ViewStyle } from 'react-native' -import type { LinearGradientProps } from 'react-native-linear-gradient' +import { ErrorBoundary } from '@fedi/common/components/ErrorBoundary' import { selectMatrixAuth } from '@fedi/common/redux' import { MatrixEvent } from '@fedi/common/types' -import { isPaymentEvent } from '@fedi/common/utils/matrix' +import { + isDeletedEvent, + isFileEvent, + isImageEvent, + isPaymentEvent, + isTextEvent, + isVideoEvent, +} from '@fedi/common/utils/matrix' import { useAppSelector } from '../../../state/hooks' -import { OptionalGradient } from '../../ui/OptionalGradient' +import ChatDeletedEvent from './ChatDeletedEvent' +import ChatFileEvent from './ChatFileEvent' +import ChatImageEvent from './ChatImageEvent' import ChatPaymentEvent from './ChatPaymentEvent' -import MessageContents from './MessageContents' +import ChatTextEvent from './ChatTextEvent' +import ChatVideoEvent from './ChatVideoEvent' +import { MessageItemError } from './MessageItemError' type Props = { event: MatrixEvent last?: boolean + fullWidth?: boolean + isPublic?: boolean } -const ChatEvent: React.FC = ({ event, last = false }: Props) => { +const ChatEvent: React.FC = ({ + event, + last = false, + fullWidth = true, + // Defaults to true so we don't default to loading chat events with media + isPublic = true, +}: Props) => { const { theme } = useTheme() const matrixAuth = useAppSelector(selectMatrixAuth) const isMe = event.senderId === matrixAuth?.userId const isQueued = false - const isPayment = isPaymentEvent(event) + const isText = isTextEvent(event) - let bubbleGradient: LinearGradientProps | undefined const bubbleContainerStyles: StyleProp[] = [ styles(theme).bubbleContainer, ] - const bubbleInnerStyles: StyleProp[] = [ - styles(theme).bubbleInner, - ] - const textStyles: StyleProp[] = [ - styles(theme).messageText, - ] // Set alignment (left/right) based on sender if (isMe) { @@ -43,52 +55,53 @@ const ChatEvent: React.FC = ({ event, last = false }: Props) => { bubbleContainerStyles.push(styles(theme).leftAlignedMessage) } - if (isPayment) { - bubbleInnerStyles.push(styles(theme).orangeBubble) - } else if (isMe) { - if (last) { - bubbleContainerStyles.push(styles(theme).lastSentMessage) - } - bubbleGradient = { - colors: ['rgba(255, 255, 255, 0.2)', 'rgba(255, 255, 255, 0)'], - start: { x: 0, y: 0 }, - end: { x: 0, y: 1 }, - } - bubbleInnerStyles.push(styles(theme).blueBubble) - textStyles.push(styles(theme).sentMessageText) - } else { - if (last) { - bubbleContainerStyles.push(styles(theme).lastReceivedMessage) - } - bubbleInnerStyles.push(styles(theme).greyBubble) - textStyles.push(styles(theme).receivedMessageText) + if (last && isText) { + bubbleContainerStyles.push( + isMe + ? styles(theme).lastSentMessage + : styles(theme).lastReceivedMessage, + ) } + + if ( + isPublic && + (isImageEvent(event) || isFileEvent(event) || isVideoEvent(event)) + ) { + return null + } + return ( - - - - - - {isPayment ? ( + }> + + + + + {isText ? ( + + ) : isPaymentEvent(event) ? ( - ) : ( - - )} - + ) : isImageEvent(event) ? ( + + ) : isFileEvent(event) ? ( + + ) : isVideoEvent(event) ? ( + + ) : isDeletedEvent(event) ? ( + + ) : null} + - + ) } @@ -105,13 +118,12 @@ const styles = (theme: Theme) => maxWidth: theme.sizes.maxMessageWidth, overflow: 'hidden', }, - bubbleInner: { - padding: 10, - }, contentContainer: { flexDirection: 'column', alignItems: 'flex-start', justifyContent: 'flex-end', + }, + fullWidth: { width: '100%', }, messageContainer: { @@ -129,25 +141,6 @@ const styles = (theme: Theme) => lastSentMessage: { borderBottomRightRadius: 4, }, - greyBubble: { - backgroundColor: theme.colors.extraLightGrey, - }, - blueBubble: { - backgroundColor: theme.colors.blue, - }, - orangeBubble: { - backgroundColor: theme.colors.orange, - }, - messageText: { - textAlign: 'left', - lineHeight: 20, - }, - receivedMessageText: { - color: theme.colors.primary, - }, - sentMessageText: { - color: theme.colors.secondary, - }, }) const areEqual = (prev: Props, curr: Props) => { @@ -162,8 +155,17 @@ const areEqual = (prev: Props, curr: Props) => { prev.event.content.status === curr.event.content.status ) } else { - return prev.event.eventId === curr.event.eventId + return ( + prev.event.eventId === curr.event.eventId && + prev.event.content.body === curr.event.content.body + ) } } +export const bubbleGradient = { + colors: ['rgba(255, 255, 255, 0.2)', 'rgba(255, 255, 255, 0)'], + start: { x: 0, y: 0 }, + end: { x: 0, y: 1 }, +} + export default React.memo(ChatEvent, areEqual) diff --git a/ui/native/components/feature/chat/ChatEventCollection.tsx b/ui/native/components/feature/chat/ChatEventCollection.tsx index 7776e9f..75574b0 100644 --- a/ui/native/components/feature/chat/ChatEventCollection.tsx +++ b/ui/native/components/feature/chat/ChatEventCollection.tsx @@ -1,9 +1,9 @@ import { Text, Theme, useTheme } from '@rneui/themed' -import React from 'react' +import isEqual from 'lodash/isEqual' +import React, { memo, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { Pressable, StyleSheet, View } from 'react-native' -import { ErrorBoundary } from '@fedi/common/components/ErrorBoundary' import { selectMatrixAuth, selectMatrixRoomMembers } from '@fedi/common/redux' import { MatrixEvent, MatrixRoomMember } from '@fedi/common/types' import dateUtils from '@fedi/common/utils/DateUtils' @@ -12,107 +12,120 @@ import { useAppSelector } from '../../../state/hooks' import SvgImage from '../../ui/SvgImage' import ChatAvatar from './ChatAvatar' import ChatEvent from './ChatEvent' -import { MessageItemError } from './MessageItemError' interface Props { roomId: string collection: MatrixEvent[][] onSelect: (userId: string) => void showUsernames?: boolean + isPublic?: boolean } -const ChatEventCollection: React.FC = ({ - roomId, - collection, - onSelect, - showUsernames, -}: Props) => { - const { theme } = useTheme() - const { t } = useTranslation() +const ChatEventCollection: React.FC = memo( + ({ roomId, collection, onSelect, showUsernames, isPublic }: Props) => { + const { theme } = useTheme() + const { t } = useTranslation() - const matrixAuth = useAppSelector(selectMatrixAuth) - const roomMembers = useAppSelector(s => selectMatrixRoomMembers(s, roomId)) + const matrixAuth = useAppSelector(selectMatrixAuth) + const roomMembers = useAppSelector(s => + selectMatrixRoomMembers(s, roomId), + ) - const handlePress = (member?: MatrixRoomMember) => - member && member?.membership !== 'leave' && onSelect(member.id) + const handlePress = useCallback( + (member?: MatrixRoomMember) => + member && member?.membership !== 'leave' && onSelect(member.id), + [onSelect], + ) - const earliestEvent = collection.slice(-1)[0].slice(-1)[0] + const earliestEvent = useMemo( + () => collection.slice(-1)[0].slice(-1)[0], + [collection], + ) - const style = styles(theme) + const style = useMemo(() => styles(theme), [theme]) - return ( - - {earliestEvent.timestamp && ( - - - {dateUtils.formatMessageItemTimestamp( - earliestEvent.timestamp / 1000, - )} - - - )} - - {collection.map((events, index) => { - if (!events.length) return null - const sentBy = events[0].senderId || '' - - const roomMember = roomMembers.find(m => m.id === sentBy) - const isMe = sentBy === matrixAuth?.userId - const hasLeft = roomMember?.membership === 'leave' - const isBanned = roomMember?.membership === 'ban' - const isAdmin = roomMember?.powerLevel === 100 - const displayName = isBanned - ? t('feature.chat.removed-member') - : hasLeft - ? t('feature.chat.former-member') - : roomMember?.displayName || '...' - return ( - - {showUsernames && !isMe && ( - - {displayName} - {isAdmin && ( - - )} - + return ( + + {earliestEvent.timestamp && ( + + + {dateUtils.formatMessageItemTimestamp( + earliestEvent.timestamp / 1000, )} - - {!isMe && showUsernames && ( - handlePress(roomMember)} - onLongPress={() => - handlePress(roomMember) - }> - - + + + )} + + {collection.map((events, index) => { + if (!events.length) return null + const sentBy = events[0].senderId || '' + + const roomMember = roomMembers.find( + m => m.id === sentBy, + ) + const isMe = sentBy === matrixAuth?.userId + const hasLeft = roomMember?.membership === 'leave' + const isBanned = roomMember?.membership === 'ban' + const isAdmin = roomMember?.powerLevel === 100 + const displayName = isBanned + ? t('feature.chat.removed-member') + : hasLeft + ? t('feature.chat.former-member') + : roomMember?.displayName || '...' + return ( + + {showUsernames && !isMe && ( + + {displayName} + {isAdmin && ( + + )} + )} - - {events.map((event, eindex) => ( - ( - - )}> + + {!isMe && showUsernames && ( + + handlePress(roomMember) + } + onLongPress={() => + handlePress(roomMember) + }> + + + )} + + {events.map((event, eindex) => ( - - ))} + ))} + - - ) - })} + ) + })} + - - ) -} + ) + }, + (prev, curr) => isEqual(prev.collection, curr.collection), +) const styles = (theme: Theme) => StyleSheet.create({ diff --git a/ui/native/components/feature/chat/ChatFileEvent.tsx b/ui/native/components/feature/chat/ChatFileEvent.tsx new file mode 100644 index 0000000..b43e576 --- /dev/null +++ b/ui/native/components/feature/chat/ChatFileEvent.tsx @@ -0,0 +1,161 @@ +import { Text, Theme, useTheme } from '@rneui/themed' +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { + ActivityIndicator, + Platform, + Pressable, + StyleSheet, + View, +} from 'react-native' +import { TemporaryDirectoryPath, exists } from 'react-native-fs' +import Share from 'react-native-share' + +import { useToast } from '@fedi/common/hooks/toast' +import { MatrixEventContentType } from '@fedi/common/utils/matrix' +import { formatFileSize } from '@fedi/common/utils/media' + +import { setSelectedChatMessage } from '@fedi/common/redux' +import { MatrixEvent } from '@fedi/common/types' +import { fedimint } from '../../../bridge' +import { useAppDispatch } from '../../../state/hooks' +import { pathJoin, prefixFileUri } from '../../../utils/media' +import SvgImage from '../../ui/SvgImage' + +type ChatImageEventProps = { + event: MatrixEvent> +} + +const ChatFileEvent: React.FC = ({ + event, +}: ChatImageEventProps) => { + const [isLoading, setIsLoading] = useState(false) + const { theme } = useTheme() + const toast = useToast() + const { t } = useTranslation() + const dispatch = useAppDispatch() + + const handleLongPress = () => { + dispatch(setSelectedChatMessage(event)) + } + + const handleDownload = useCallback(async () => { + setIsLoading(true) + + try { + const path = pathJoin(TemporaryDirectoryPath, event.content.body) + + // bridge downloads the file to the path we provide + const downloadedFilePath = await fedimint.matrixDownloadFile( + path, + event.content, + ) + + const downloadedFileUri = prefixFileUri(downloadedFilePath) + + if (await exists(downloadedFileUri)) { + const filename = + Platform.OS === 'android' + ? event.content.body.replace(/\.[a-z]+$/, '') + : event.content.body + + try { + await Share.open({ + filename, + type: event.content.info.mimetype, + url: downloadedFileUri, + }) + + toast.show({ + content: t('feature.chat.file-saved'), + status: 'success', + }) + } catch { + /* no-op*/ + } + } + } catch (err) { + toast.error(t, err, 'errors.unknown-error') + } finally { + setIsLoading(false) + } + }, [event.content, t, toast]) + + const style = styles(theme) + + return ( + + + + + + + + {event.content.body} + + + {formatFileSize(event.content.info.size ?? 0)} + + + + + + {isLoading ? ( + + ) : ( + + )} + + + + ) +} + +const styles = (theme: Theme) => + StyleSheet.create({ + attachmentContentGutter: { + flex: 1, + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: theme.spacing.sm, + }, + attachment: { + padding: theme.spacing.sm, + borderRadius: 8, + backgroundColor: theme.colors.offWhite, + flexDirection: 'row', + alignItems: 'center', + gap: theme.spacing.sm, + maxWidth: theme.sizes.maxMessageWidth, + width: '100%', + }, + attachmentIcon: { + width: 48, + height: 48, + padding: theme.spacing.md, + backgroundColor: theme.colors.extraLightGrey, + borderRadius: 8, + }, + attachmentContent: { + flex: 1, + flexDirection: 'column', + display: 'flex', + gap: theme.spacing.xs, + }, + attachmentSize: { + color: theme.colors.darkGrey, + }, + downloadButton: { + width: 40, + height: 40, + borderRadius: 60, + borderWidth: 1, + borderColor: theme.colors.lightGrey, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + }) + +export default ChatFileEvent diff --git a/ui/native/components/feature/chat/ChatHeader.tsx b/ui/native/components/feature/chat/ChatHeader.tsx index f07e9ae..6385cb8 100644 --- a/ui/native/components/feature/chat/ChatHeader.tsx +++ b/ui/native/components/feature/chat/ChatHeader.tsx @@ -1,6 +1,6 @@ import { useNavigation } from '@react-navigation/native' import { Text, Theme, useTheme } from '@rneui/themed' -import React from 'react' +import React, { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { StyleSheet } from 'react-native' @@ -26,9 +26,9 @@ const ChatHeader: React.FC = () => { const [hasViewedMemberQr, completeViewedMemberQr] = useNuxStep('hasViewedMemberQr') - if (shouldShowUpgradeChat) return null + const style = useMemo(() => styles(theme), [theme]) - const style = styles(theme) + if (shouldShowUpgradeChat) return null return ( <> diff --git a/ui/native/components/feature/chat/ChatImageEvent.tsx b/ui/native/components/feature/chat/ChatImageEvent.tsx new file mode 100644 index 0000000..e55fb3e --- /dev/null +++ b/ui/native/components/feature/chat/ChatImageEvent.tsx @@ -0,0 +1,138 @@ +import { Text, Theme, useTheme } from '@rneui/themed' +import { useEffect, useState } from 'react' +import { + ActivityIndicator, + Image, + Pressable, + StyleSheet, + View, +} from 'react-native' +import { TemporaryDirectoryPath, exists } from 'react-native-fs' + +import { setSelectedChatMessage } from '@fedi/common/redux' +import { MatrixEvent } from '@fedi/common/types' +import { makeLog } from '@fedi/common/utils/log' +import { MatrixEventContentType } from '@fedi/common/utils/matrix' +import { scaleAttachment } from '@fedi/common/utils/media' +import { useNavigation } from '@react-navigation/native' +import { useTranslation } from 'react-i18next' +import { fedimint } from '../../../bridge' +import { useAppDispatch } from '../../../state/hooks' +import { pathJoin, prefixFileUri } from '../../../utils/media' +import SvgImage from '../../ui/SvgImage' + +type ChatImageEventProps = { + event: MatrixEvent> +} + +const log = makeLog('ChatImageEvent') + +const ChatImageEvent: React.FC = ({ + event, +}: ChatImageEventProps) => { + const [isLoading, setIsLoading] = useState(true) + const [isError, setIsError] = useState(false) + const [uri, setURI] = useState('') + const { theme } = useTheme() + const { t } = useTranslation() + const dispatch = useAppDispatch() + const navigation = useNavigation() + + const resolvedUri = prefixFileUri(uri) + + const handleLongPress = () => { + dispatch(setSelectedChatMessage(event)) + } + + useEffect(() => { + const loadImage = async () => { + try { + const path = pathJoin( + TemporaryDirectoryPath, + event.content.body, + ) + + const imagePath = await fedimint.matrixDownloadFile( + path, + event.content, + ) + + const imageUri = prefixFileUri(imagePath) + + if (await exists(imageUri)) { + setURI(imageUri) + } else { + throw new Error('Image does not exist in fs') + } + } catch (err) { + log.error('Failed to load image', err) + setIsError(true) + } finally { + setIsLoading(false) + } + } + + loadImage() + }, [event.content]) + + const style = styles(theme) + + const dimensions = scaleAttachment( + event.content.info.w, + event.content.info.h, + theme.sizes.maxMessageWidth, + 400, + ) + + const imageBaseStyle = [style.imageBase, dimensions] + + return isLoading || !uri || isError ? ( + + {isError ? ( + + + + {t('errors.failed-to-load-image')} + + + ) : ( + + )} + + ) : ( + + navigation.navigate('ChatImageViewer', { uri: resolvedUri }) + } + onLongPress={handleLongPress}> + setIsError(true)} + /> + + ) +} + +const styles = (theme: Theme) => + StyleSheet.create({ + imageBase: { + maxWidth: theme.sizes.maxMessageWidth, + maxHeight: 400, + backgroundColor: theme.colors.extraLightGrey, + padding: 16, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + }, + imageError: { + flexDirection: 'column', + gap: theme.spacing.md, + alignItems: 'center', + }, + errorCaption: { + color: theme.colors.darkGrey, + }, + }) + +export default ChatImageEvent diff --git a/ui/native/components/feature/chat/ChatPaymentEvent.tsx b/ui/native/components/feature/chat/ChatPaymentEvent.tsx index 563929c..0aa1f5c 100644 --- a/ui/native/components/feature/chat/ChatPaymentEvent.tsx +++ b/ui/native/components/feature/chat/ChatPaymentEvent.tsx @@ -12,7 +12,9 @@ import amountUtils from '@fedi/common/utils/AmountUtils' import { fedimint } from '../../../bridge' import { NavigationHook } from '../../../types/navigation' import HoloLoader from '../../ui/HoloLoader' +import { OptionalGradient } from '../../ui/OptionalGradient' import SvgImage, { SvgImageSize } from '../../ui/SvgImage' +import { bubbleGradient } from './ChatEvent' import ReceiveForeignEcashOverlay from './ReceiveForeignEcashOverlay' type Props = { @@ -105,7 +107,9 @@ const ChatPaymentEvent: React.FC = ({ event }: Props) => { } return ( - <> + {messageText} {extra || null} {isHandlingForeignEcash && ( @@ -119,7 +123,7 @@ const ChatPaymentEvent: React.FC = ({ event }: Props) => { }} /> )} - + ) } @@ -154,6 +158,12 @@ const styles = (theme: Theme) => messageText: { color: theme.colors.secondary, }, + orangeBubble: { + backgroundColor: theme.colors.orange, + }, + bubbleInner: { + padding: 10, + }, }) export default ChatPaymentEvent diff --git a/ui/native/components/feature/chat/ChatTextEvent.tsx b/ui/native/components/feature/chat/ChatTextEvent.tsx new file mode 100644 index 0000000..065f557 --- /dev/null +++ b/ui/native/components/feature/chat/ChatTextEvent.tsx @@ -0,0 +1,68 @@ +import { selectMatrixAuth, setSelectedChatMessage } from '@fedi/common/redux' +import { MatrixEvent } from '@fedi/common/types' +import { MatrixEventContentType } from '@fedi/common/utils/matrix' +import { Theme, useTheme } from '@rneui/themed' +import { Pressable, StyleSheet } from 'react-native' +import { useAppDispatch, useAppSelector } from '../../../state/hooks' +import { OptionalGradient } from '../../ui/OptionalGradient' +import { bubbleGradient } from './ChatEvent' +import MessageContents from './MessageContents' + +type Props = { + event: MatrixEvent> +} + +const ChatTextEvent: React.FC = ({ event }) => { + const matrixAuth = useAppSelector(selectMatrixAuth) + const { theme } = useTheme() + const style = styles(theme) + const dispatch = useAppDispatch() + + const handleLongPress = () => { + dispatch(setSelectedChatMessage(event)) + } + + const isMe = event.senderId === matrixAuth?.userId + + return ( + + + + + + ) +} + +const styles = (theme: Theme) => + StyleSheet.create({ + bubbleInner: { + padding: 10, + }, + greyBubble: { + backgroundColor: theme.colors.extraLightGrey, + }, + blueBubble: { + backgroundColor: theme.colors.blue, + }, + incomingText: { + color: theme.colors.primary, + }, + outgoingText: { + color: theme.colors.secondary, + }, + }) + +export default ChatTextEvent diff --git a/ui/native/components/feature/chat/ChatTile.tsx b/ui/native/components/feature/chat/ChatTile.tsx index 76ccdc8..b469f59 100644 --- a/ui/native/components/feature/chat/ChatTile.tsx +++ b/ui/native/components/feature/chat/ChatTile.tsx @@ -33,6 +33,10 @@ const ChatTile = ({ room, onSelect, onLongPress }: ChatTileProps) => { [showUnreadIndicator], ) const previewMessage = useMemo(() => room?.preview?.body, [room?.preview]) + const previewMessageIsDeleted = useMemo( + () => room?.preview?.isDeleted, + [room?.preview], + ) return ( { HACK: public rooms don't show a preview message so you have to click into it to paginate backwards TODO: Replace with proper room previews */} - {room.isPublic && room.broadcastOnly + {previewMessageIsDeleted + ? t('feature.chat.message-deleted') + : room.isPublic && room.broadcastOnly ? t('feature.chat.click-here-for-announcements') : t('feature.chat.no-messages')} diff --git a/ui/native/components/feature/chat/ChatVideoEvent.tsx b/ui/native/components/feature/chat/ChatVideoEvent.tsx new file mode 100644 index 0000000..fb0af19 --- /dev/null +++ b/ui/native/components/feature/chat/ChatVideoEvent.tsx @@ -0,0 +1,185 @@ +import { Text, Theme, useTheme } from '@rneui/themed' +import { useEffect, useRef, useState } from 'react' +import { + ActivityIndicator, + Platform, + StyleSheet, + TouchableOpacity, + View, +} from 'react-native' +import { TemporaryDirectoryPath, exists } from 'react-native-fs' + +import { setSelectedChatMessage } from '@fedi/common/redux' +import { MatrixEvent } from '@fedi/common/types' +import { makeLog } from '@fedi/common/utils/log' +import { MatrixEventContentType } from '@fedi/common/utils/matrix' +import { scaleAttachment } from '@fedi/common/utils/media' +import { useNavigation } from '@react-navigation/native' +import { useTranslation } from 'react-i18next' +import Video from 'react-native-video' +import { fedimint } from '../../../bridge' +import { useAppDispatch } from '../../../state/hooks' +import { pathJoin, prefixFileUri } from '../../../utils/media' +import SvgImage from '../../ui/SvgImage' + +type ChatVideoEventProps = { + event: MatrixEvent> +} + +const log = makeLog('ChatVideoEvent') + +const ChatVideoEvent: React.FC = ({ + event, +}: ChatVideoEventProps) => { + const [isLoading, setIsLoading] = useState(true) + const [isError, setIsError] = useState(false) + const [uri, setURI] = useState('') + const [paused, setPaused] = useState(true) + const { theme } = useTheme() + const { t } = useTranslation() + const videoRef = useRef