diff --git a/.github/workflows/auth-lint.yml b/.github/workflows/auth-lint.yml index e7c42e1a6b..50e73ba13f 100644 --- a/.github/workflows/auth-lint.yml +++ b/.github/workflows/auth-lint.yml @@ -9,7 +9,7 @@ on: - ".github/workflows/auth-lint.yml" env: - FLUTTER_VERSION: "3.19.3" + FLUTTER_VERSION: "3.22.2" jobs: lint: diff --git a/.github/workflows/auth-release.yml b/.github/workflows/auth-release.yml index cf3749ae6a..fb9781e2db 100644 --- a/.github/workflows/auth-release.yml +++ b/.github/workflows/auth-release.yml @@ -29,7 +29,7 @@ on: - "auth-v*" env: - FLUTTER_VERSION: "3.19.3" + FLUTTER_VERSION: "3.22.2" jobs: build-ubuntu: diff --git a/.github/workflows/web-deploy-staff.yml b/.github/workflows/infra-deploy-staff.yml similarity index 63% rename from .github/workflows/web-deploy-staff.yml rename to .github/workflows/infra-deploy-staff.yml index 854e163644..dd68a14a26 100644 --- a/.github/workflows/web-deploy-staff.yml +++ b/.github/workflows/infra-deploy-staff.yml @@ -1,45 +1,43 @@ name: "Deploy (staff)" on: - # Run on every push to main that changes web/apps/staff/ + # Run on every push to main that changes infra/staff/ push: branches: [main] paths: - - "web/apps/staff/**" - - ".github/workflows/web-deploy-staff.yml" + - "infra/staff/**" + - ".github/workflows/infra-deploy-staff.yml" # Also allow manually running the workflow workflow_dispatch: jobs: - deploy: + lint: runs-on: ubuntu-latest defaults: run: - working-directory: web + working-directory: infra/staff steps: - name: Checkout code uses: actions/checkout@v4 - with: - submodules: recursive - name: Setup node and enable yarn caching uses: actions/setup-node@v4 with: node-version: 20 cache: "yarn" - cache-dependency-path: "web/yarn.lock" + cache-dependency-path: "infra/staff/yarn.lock" - name: Install dependencies run: yarn install - - name: Build staff - run: yarn build:staff + - name: Build + run: yarn build - - name: Publish staff + - name: Publish uses: cloudflare/wrangler-action@v3 with: accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} - command: pages deploy --project-name=ente --commit-dirty=true --branch=deploy/staff web/apps/staff/dist + command: pages deploy --project-name=ente --commit-dirty=true --branch=deploy/staff infra/staff/dist diff --git a/.github/workflows/infra-lint-staff.yml b/.github/workflows/infra-lint-staff.yml new file mode 100644 index 0000000000..5c2894281e --- /dev/null +++ b/.github/workflows/infra-lint-staff.yml @@ -0,0 +1,34 @@ +name: "Lint (staff)" + +on: + # Run on every push to a branch other than main that changes infra/staff/ + push: + branches-ignore: [main] + paths: + - "infra/staff/**" + - ".github/workflows/infra-deploy-staff.yml" + +jobs: + deploy: + runs-on: ubuntu-latest + + defaults: + run: + working-directory: infra/staff + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup node and enable yarn caching + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "yarn" + cache-dependency-path: "infra/staff/yarn.lock" + + - name: Install dependencies + run: yarn install + + - name: Lint + run: yarn lint diff --git a/.github/workflows/mobile-internal-release.yml b/.github/workflows/mobile-internal-release.yml index fac4eb1d2f..2eb9979bf9 100644 --- a/.github/workflows/mobile-internal-release.yml +++ b/.github/workflows/mobile-internal-release.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: # Allow manually running the action env: - FLUTTER_VERSION: "3.22.0" + FLUTTER_VERSION: "3.22.2" jobs: build: diff --git a/.github/workflows/mobile-lint.yml b/.github/workflows/mobile-lint.yml index 8f231079f3..59bfcbbf67 100644 --- a/.github/workflows/mobile-lint.yml +++ b/.github/workflows/mobile-lint.yml @@ -10,7 +10,7 @@ on: env: - FLUTTER_VERSION: "3.22.0" + FLUTTER_VERSION: "3.22.2" jobs: lint: diff --git a/.github/workflows/mobile-release.yml b/.github/workflows/mobile-release.yml index 35cc217e67..363f232c80 100644 --- a/.github/workflows/mobile-release.yml +++ b/.github/workflows/mobile-release.yml @@ -9,7 +9,7 @@ on: - "photos-v*" env: - FLUTTER_VERSION: "3.22.0" + FLUTTER_VERSION: "3.22.2" jobs: build: diff --git a/README.md b/README.md index 00a786b96e..d4c6bc6a24 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ want to give back, please check out Ente Photos or spread the word. [](https://apps.apple.com/app/id6444121398) [](https://play.google.com/store/apps/details?id=io.ente.auth) [](https://f-droid.org/packages/io.ente.auth/) -[](https://github.com/ente-io/ente/releases?q=tag%3Aauth-v2) +[](https://github.com/ente-io/ente/releases?q=tag%3Aauth-v3) [](https://auth.ente.io) diff --git a/SECURITY.md b/SECURITY.md index 59efbca831..ea26d26a40 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,51 +1,54 @@ +# Security Policy + Ente believes that working with security researchers across the globe is crucial to keeping our users safe. If you believe you've found a security issue in our -product or service, we encourage you to notify us, by email (security@ente.io) -or by [filling this -form](https://github.com/ente-io/ente/security/advisories/new) We welcome -working with you to resolve the issue promptly. Thanks in advance! +product or service, we encourage you to notify us by email at security@ente.io +or by +[filling out this form](https://github.com/ente-io/ente/security/advisories/new). +We welcome working with you to resolve the issue promptly. Thanks in advance! ## Disclosure Policy -- Let us know as soon as possible upon discovery of a potential security issue, - and we'll make every effort to quickly resolve the issue. -- Provide us a reasonable amount of time to resolve the issue before any - disclosure to the public or a third-party. We may publicly disclose the issue - before resolving it, if appropriate. -- Make a good faith effort to avoid privacy violations, destruction of data, and - interruption or degradation of our service. Only interact with accounts you - own or with explicit permission of the account holder. -- If you would like to encrypt your report, please use the PGP key with long ID - `E273695C0403F34F74171932DF6DDDE98EBD2394` (available in the public keyserver - pool). +- Let us know as soon as possible upon discovery of a potential security + issue, and we'll make every effort to quickly resolve the issue. +- Provide us with a reasonable amount of time to resolve the issue before any + disclosure to the public or a third party. We may publicly disclose the + issue before resolving it if appropriate. +- Make a good faith effort to avoid privacy violations, destruction of data, + and interruption or degradation of our service. Only interact with accounts + you own or with the explicit permission of the account holder. +- If you would like to encrypt your report, please use the PGP key with long + ID `E273695C0403F34F74171932DF6DDDE98EBD2394` (available in the public + keyserver pool). ## In-scope -- Security issues in any current release of Ente's services. Product downloads - are available at https://ente.io. Source code is available at - https://github.com/ente-io. +- Security issues in any current release of Ente's services. Product downloads + are available at [https://ente.io](https://ente.io). Source code is + available at [https://github.com/ente-io](https://github.com/ente-io). ## Exclusions -The following bug classes are out-of scope: +The following bug classes are out of scope: -- Bugs that are already reported on any of [Ente's issue - trackers](https://github.com/ente-io), or that we already know of (Note that - some of our issue tracking is private) -- Issues in an upstream software dependency (ex: Flutter, Next.js etc) which are - already reported to the upstream maintainer -- Attacks requiring physical access to a user's device -- Self-XSS -- Issues related to software or protocols not under ente's control -- Vulnerabilities in outdated versions of ente -- Missing security best practices that do not directly lead to a vulnerability -- Issues that do not have any impact on the general public +- Bugs that are already reported on any of + [Ente's issue trackers](https://github.com/ente-io) or that we already know + of (note that some of our issue tracking is private). +- Issues in an upstream software dependency (e.g., Flutter, Next.js, etc.) + that are already reported to the upstream maintainer. +- Attacks requiring physical access to a user's device. +- Self-XSS. +- Issues related to software or protocols not under Ente's control. +- Vulnerabilities in outdated versions of Ente. +- Missing security best practices that do not directly lead to a + vulnerability. +- Issues that do not have any impact on the general public. While researching, we'd like to ask you to refrain from: -- Denial of service -- Spamming -- Social engineering (including phishing) of Ente staff or contractors -- Any physical attempts against Ente property or data centers +- Denial of service +- Spamming +- Social engineering (including phishing) of Ente staff or contractors +- Any physical attempts against Ente property or data centers Thank you for helping keep Ente and our users safe! diff --git a/auth/README.md b/auth/README.md index 6382812de2..d419a86755 100644 --- a/auth/README.md +++ b/auth/README.md @@ -12,7 +12,7 @@ multi-device sync. ### Android This repository's [GitHub -releases](https://github.com/ente-io/ente/releases?q=tag%3Aauth-v2) +releases](https://github.com/ente-io/ente/releases?q=tag%3Aauth-v3) contains APKs, built straight from source. These builds keep themselves updated, without relying on third party stores. @@ -33,7 +33,7 @@ You can alternatively install the build from PlayStore or F-Droid. ### Desktop -You can [**download**](https://github.com/ente-io/ente/releases?q=tag%3Aauth-v2) +You can [**download**](https://github.com/ente-io/ente/releases?q=tag%3Aauth-v3) a native desktop app from this repository's GitHub releases. The desktop app works on Windows, Linux and macOS. diff --git a/auth/android/app/src/main/res/mipmap-anydpi-v26/launcher_icon.xml b/auth/android/app/src/main/res/mipmap-anydpi-v26/launcher_icon.xml index 7e91a578c4..28f41b3c4d 100644 --- a/auth/android/app/src/main/res/mipmap-anydpi-v26/launcher_icon.xml +++ b/auth/android/app/src/main/res/mipmap-anydpi-v26/launcher_icon.xml @@ -2,4 +2,5 @@ + diff --git a/auth/assets/custom-icons/_data/custom-icons.json b/auth/assets/custom-icons/_data/custom-icons.json index 5f15bba746..a84ddd1525 100644 --- a/auth/assets/custom-icons/_data/custom-icons.json +++ b/auth/assets/custom-icons/_data/custom-icons.json @@ -17,11 +17,17 @@ { "title": "AscendEX" }, + { + "title": "Bitfinex" + }, { "title": "BitMEX" }, { - "title": "Bitfinex" + "title": "BitSkins" + }, + { + "title": "Bitstamp" }, { "title": "Bitvavo", @@ -33,15 +39,11 @@ { "title": "Bloom Host", "slug": "bloom_host", - "altNames": [ - "Bloom Host Billing" - ] + "altNames": ["Bloom Host Billing"] }, { "title": "BorgBase", - "altNames": [ - "borg" - ], + "altNames": ["borg"], "slug": "BorgBase" }, { @@ -54,16 +56,19 @@ { "title": "CERN" }, + { + "title": "ChangeNOW" + }, { "title": "Channel Island Hosting", "slug": "cih", "hex": "D14633" }, { - "title": "ConfigCat" + "title": "Cloudflare" }, { - "title": "Cloudflare" + "title": "ConfigCat" }, { "title": "Control D", @@ -75,17 +80,21 @@ }, { "title": "DCS", - "altNames": [ - "Digital Combat Simulator" - ], + "altNames": ["Digital Combat Simulator"], "slug": "dcs" }, { "title": "DEGIRO" }, + { + "title": "DirectAdmin" + }, { "title": "Discourse" }, + { + "title": "DMarket" + }, { "title": "Doppler" }, @@ -123,14 +132,15 @@ { "title": "GitLab" }, + { + "title": "GMX" + }, { "title": "Google" }, { "title": "Gosuslugi", - "altNames": [ - "Госуслуги" - ], + "altNames": ["Госуслуги"], "slug": "Gosuslugi" }, { @@ -141,21 +151,28 @@ "slug": "healthchecks" }, { - "title": "ING" + "title": "Hivelocity" }, { - "title": "INWX" + "title": "IceDrive", + "slug": "Icedrive" + }, + { + "title": "ING" }, { "title": "Instagram" }, { - "title": "IVPN", - "slug": "IVPN" + "title": "INWX" }, { - "title": "IceDrive", - "slug": "Icedrive" + "title": "Itch.io", + "slug": "itch_io" + }, + { + "title": "IVPN", + "slug": "IVPN" }, { "title": "Jagex", @@ -164,10 +181,6 @@ { "title": "Kagi" }, - { - "title": "KPN", - "color": "00CC00" - }, { "title": "Kick", "hex": "53FC19" @@ -178,6 +191,10 @@ { "title": "Koofr" }, + { + "title": "KPN", + "color": "00CC00" + }, { "title": "Kraken", "hex": "5848D5" @@ -199,52 +216,48 @@ { "title": "Local", "slug": "local_wp", - "altNames": [ - "LocalWP", - "Local WP", - "Local Wordpress" - ] + "altNames": ["LocalWP", "Local WP", "Local Wordpress"] + }, + { + "title": "Marketplace.tf", + "slug": "marketplacedottf" }, { "title": "Mastodon", - "altNames": [ - "mstdn", - "fediscience", - "mathstodon", - "fosstodon" - ], + "altNames": ["mstdn", "fediscience", "mathstodon", "fosstodon"], "slug": "mastodon", "hex": "6364FF" }, { "title": "Mercado Livre", "slug": "mercado_livre", - "altNames": [ - "Mercado Libre", - "MercadoLibre", - "MercadoLivre" - ] - }, - { - "title": "Murena", - "altNames": [ - "eCloud" - ], - "slug": "ecloud" + "altNames": ["Mercado Libre", "MercadoLibre", "MercadoLivre"] }, { "title": "Microsoft" }, + { + "title": "Migros" + }, { "title": "Mintos" }, { "title": "Mozilla" }, + { + "title": "Murena", + "altNames": ["eCloud"], + "slug": "ecloud" + }, { "title": "MyFRITZ!Net", "slug": "myfritz" }, + { + "title": "Name.com", + "slug": "name_com" + }, { "title": "NextDNS" }, @@ -314,6 +327,14 @@ { "title": "Proxmox" }, + { + "title": "Real-Debrid", + "slug": "real_debrid" + }, + { + "title": "Registro.br", + "slug": "registro_br" + }, { "title": "Revolt", "hex": "858585" @@ -347,6 +368,9 @@ "title": "Skiff", "hex": "EF5A3C" }, + { + "title": "Skinport" + }, { "title": "Snapchat" }, @@ -355,6 +379,9 @@ "slug": "standardnotes", "hex": "2173E6" }, + { + "title": "Surfshark" + }, { "title": "Synology DSM", "slug": "synology_dsm" @@ -366,9 +393,7 @@ }, { "title": "Techlore", - "altNames": [ - "Techlore Courses" - ] + "altNames": ["Techlore Courses", "Techlore Forums"] }, { "title": "Termius", @@ -402,6 +427,10 @@ "title": "Ubisoft", "hex": "4285f4" }, + { + "title": "Ubuntu One", + "slug": "ubuntu_one" + }, { "title": "Unity", "hex": "858585" @@ -425,28 +454,26 @@ "title": "WYZE", "slug": "wyze" }, + { + "title": "WorkOS", + "slug": "workos", + "altNames": ["Work OS"] + }, { "title": "X", - "altNames": [ - "twitter" - ], + "altNames": ["twitter"], "slug": "x" }, { "title": "Yandex", - "altNames": [ - "Ya", - "Яндекс" - ], + "altNames": ["Ya", "Яндекс"], "slug": "Yandex" }, { "title": "YNAB", - "altNames": [ - "You Need A Budget" - ], + "altNames": ["You Need A Budget"], "slug": "ynab", "hex": "3B5EDA" } ] -} \ No newline at end of file +} diff --git a/auth/assets/custom-icons/icons/Notesnook.svg b/auth/assets/custom-icons/icons/Notesnook.svg deleted file mode 100644 index e37a18a417..0000000000 --- a/auth/assets/custom-icons/icons/Notesnook.svg +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/auth/assets/custom-icons/icons/bitskins.svg b/auth/assets/custom-icons/icons/bitskins.svg new file mode 100644 index 0000000000..4dde228c30 --- /dev/null +++ b/auth/assets/custom-icons/icons/bitskins.svg @@ -0,0 +1,3 @@ + + + diff --git a/auth/assets/custom-icons/icons/bitstamp.svg b/auth/assets/custom-icons/icons/bitstamp.svg new file mode 100755 index 0000000000..23fc94f77c --- /dev/null +++ b/auth/assets/custom-icons/icons/bitstamp.svg @@ -0,0 +1,28 @@ + + + + + + diff --git a/auth/assets/custom-icons/icons/changenow.svg b/auth/assets/custom-icons/icons/changenow.svg new file mode 100644 index 0000000000..b475e321d7 --- /dev/null +++ b/auth/assets/custom-icons/icons/changenow.svg @@ -0,0 +1,15 @@ + + + + + + + + diff --git a/auth/assets/custom-icons/icons/directadmin.svg b/auth/assets/custom-icons/icons/directadmin.svg new file mode 100644 index 0000000000..c4436b9485 --- /dev/null +++ b/auth/assets/custom-icons/icons/directadmin.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/auth/assets/custom-icons/icons/dmarket.svg b/auth/assets/custom-icons/icons/dmarket.svg new file mode 100644 index 0000000000..53fe77e0ac --- /dev/null +++ b/auth/assets/custom-icons/icons/dmarket.svg @@ -0,0 +1 @@ +dmt \ No newline at end of file diff --git a/auth/assets/custom-icons/icons/gmx.svg b/auth/assets/custom-icons/icons/gmx.svg new file mode 100644 index 0000000000..293cbdaf06 --- /dev/null +++ b/auth/assets/custom-icons/icons/gmx.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/auth/assets/custom-icons/icons/habbo.svg b/auth/assets/custom-icons/icons/habbo.svg index 2866bc3638..407399507e 100644 --- a/auth/assets/custom-icons/icons/habbo.svg +++ b/auth/assets/custom-icons/icons/habbo.svg @@ -1,9 +1,23 @@ - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + diff --git a/auth/assets/custom-icons/icons/hivelocity.svg b/auth/assets/custom-icons/icons/hivelocity.svg new file mode 100644 index 0000000000..b84451a4c2 --- /dev/null +++ b/auth/assets/custom-icons/icons/hivelocity.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/auth/assets/custom-icons/icons/itch_io.svg b/auth/assets/custom-icons/icons/itch_io.svg new file mode 100644 index 0000000000..1450bb2fb2 --- /dev/null +++ b/auth/assets/custom-icons/icons/itch_io.svg @@ -0,0 +1,6 @@ + + + diff --git a/auth/assets/custom-icons/icons/local_wp.svg b/auth/assets/custom-icons/icons/local_wp.svg index 3dbe63b2af..f37d988ec4 100644 --- a/auth/assets/custom-icons/icons/local_wp.svg +++ b/auth/assets/custom-icons/icons/local_wp.svg @@ -1,9 +1,24 @@ - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + diff --git a/auth/assets/custom-icons/icons/marketplacedottf.svg b/auth/assets/custom-icons/icons/marketplacedottf.svg new file mode 100644 index 0000000000..bc56a92cec --- /dev/null +++ b/auth/assets/custom-icons/icons/marketplacedottf.svg @@ -0,0 +1,3 @@ + + + diff --git a/auth/assets/custom-icons/icons/mercado_livre.svg b/auth/assets/custom-icons/icons/mercado_livre.svg index 8eeb1b94b5..c4401f6945 100644 --- a/auth/assets/custom-icons/icons/mercado_livre.svg +++ b/auth/assets/custom-icons/icons/mercado_livre.svg @@ -1,10 +1,11 @@ - - - - - - - - - + + + + + + + + + + diff --git a/auth/assets/custom-icons/icons/migros.svg b/auth/assets/custom-icons/icons/migros.svg new file mode 100644 index 0000000000..037a5f11b0 --- /dev/null +++ b/auth/assets/custom-icons/icons/migros.svg @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/auth/assets/custom-icons/icons/name_com.svg b/auth/assets/custom-icons/icons/name_com.svg new file mode 100644 index 0000000000..a33a643477 --- /dev/null +++ b/auth/assets/custom-icons/icons/name_com.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + diff --git a/auth/assets/custom-icons/icons/notesnook.svg b/auth/assets/custom-icons/icons/notesnook.svg new file mode 100644 index 0000000000..28456f86c4 --- /dev/null +++ b/auth/assets/custom-icons/icons/notesnook.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/auth/assets/custom-icons/icons/real_debrid.svg b/auth/assets/custom-icons/icons/real_debrid.svg new file mode 100644 index 0000000000..d6b616c9e3 --- /dev/null +++ b/auth/assets/custom-icons/icons/real_debrid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/auth/assets/custom-icons/icons/registro_br.svg b/auth/assets/custom-icons/icons/registro_br.svg new file mode 100644 index 0000000000..a719a6b975 --- /dev/null +++ b/auth/assets/custom-icons/icons/registro_br.svg @@ -0,0 +1,83 @@ + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/auth/assets/custom-icons/icons/sendgrid.svg b/auth/assets/custom-icons/icons/sendgrid.svg index 3b65642792..554bb37aae 100644 --- a/auth/assets/custom-icons/icons/sendgrid.svg +++ b/auth/assets/custom-icons/icons/sendgrid.svg @@ -1,9 +1,6 @@ - - - - - - - - + + + + + diff --git a/auth/assets/custom-icons/icons/skinport.svg b/auth/assets/custom-icons/icons/skinport.svg new file mode 100644 index 0000000000..0d3cf32369 --- /dev/null +++ b/auth/assets/custom-icons/icons/skinport.svg @@ -0,0 +1,3 @@ + + + diff --git a/auth/assets/custom-icons/icons/surfshark.svg b/auth/assets/custom-icons/icons/surfshark.svg new file mode 100644 index 0000000000..745b066ce9 --- /dev/null +++ b/auth/assets/custom-icons/icons/surfshark.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/auth/assets/custom-icons/icons/ubuntu_one.svg b/auth/assets/custom-icons/icons/ubuntu_one.svg new file mode 100644 index 0000000000..e22ff3f8f0 --- /dev/null +++ b/auth/assets/custom-icons/icons/ubuntu_one.svg @@ -0,0 +1,113 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + diff --git a/auth/assets/custom-icons/icons/workos.svg b/auth/assets/custom-icons/icons/workos.svg new file mode 100644 index 0000000000..d01eaad932 --- /dev/null +++ b/auth/assets/custom-icons/icons/workos.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/auth/flutter b/auth/flutter index ba39319843..761747bfc5 160000 --- a/auth/flutter +++ b/auth/flutter @@ -1 +1 @@ -Subproject commit ba393198430278b6595976de84fe170f553cc728 +Subproject commit 761747bfc538b5af34aa0d3fac380f1bc331ec49 diff --git a/auth/lib/core/errors.dart b/auth/lib/core/errors.dart index ba1310b6ca..9e36301907 100644 --- a/auth/lib/core/errors.dart +++ b/auth/lib/core/errors.dart @@ -42,3 +42,7 @@ class InvalidStateError extends AssertionError { class SrpSetupNotCompleteError extends Error {} class AuthenticatorKeyNotFound extends Error {} + +class PassKeySessionNotVerifiedError extends Error {} + +class PassKeySessionExpiredError extends Error {} diff --git a/auth/lib/l10n/arb/app_de.arb b/auth/lib/l10n/arb/app_de.arb index 0c4d29eaf3..3f027c8959 100644 --- a/auth/lib/l10n/arb/app_de.arb +++ b/auth/lib/l10n/arb/app_de.arb @@ -20,6 +20,8 @@ "codeIssuerHint": "Aussteller", "codeSecretKeyHint": "Geheimer Schlüssel", "codeAccountHint": "Konto (you@domain.com)", + "codeTagHint": "Tag", + "accountKeyType": "Art des Keys", "sessionExpired": "Sitzung abgelaufen", "@sessionExpired": { "description": "Title of the dialog when the users current session is invalid/expired" @@ -156,6 +158,7 @@ } } }, + "invalidQRCode": "Ungültiger QR-Code", "noRecoveryKeyTitle": "Kein Wiederherstellungsschlüssel?", "enterEmailHint": "Geben Sie Ihre E-Mail Adresse ein", "invalidEmailTitle": "Ungültige E-Mail Adresse", @@ -420,5 +423,18 @@ "invalidEndpoint": "Ungültiger Endpunkt", "invalidEndpointMessage": "Der eingegebene Endpunkt ist ungültig. Bitte geben Sie einen gültigen Endpunkt ein und versuchen Sie es erneut.", "endpointUpdatedMessage": "Endpunkt erfolgreich aktualisiert", - "customEndpoint": "Mit {endpoint} verbunden" + "customEndpoint": "Mit {endpoint} verbunden", + "pinText": "Anpinnen", + "unpinText": "Lösen", + "pinnedCodeMessage": "{code} wurde angepinnt", + "unpinnedCodeMessage": "{code} wurde Losgelöst", + "tags": "Tags", + "createNewTag": "Neuen Tag erstellen", + "tag": "Tag", + "create": "Erstellen", + "editTag": "Tag bearbeiten", + "deleteTagTitle": "Tag löschen?", + "deleteTagMessage": "Sind Sie sicher, dass Sie diesen Code löschen wollen? Diese Aktion ist unumkehrbar.", + "somethingWentWrongParsingCode": "Wir konnten {x} Codes nicht parsen.", + "updateNotAvailable": "Update ist nicht verfügbar" } \ No newline at end of file diff --git a/auth/lib/l10n/arb/app_en.arb b/auth/lib/l10n/arb/app_en.arb index e4d1a07a50..58ad079a0b 100644 --- a/auth/lib/l10n/arb/app_en.arb +++ b/auth/lib/l10n/arb/app_en.arb @@ -269,6 +269,7 @@ "privacy": "Privacy", "terms": "Terms", "checkForUpdates": "Check for updates", + "checkStatus": "Check status", "downloadUpdate": "Download", "criticalUpdateAvailable": "Critical update available", "updateAvailable": "Update available", @@ -417,6 +418,9 @@ "waitingForBrowserRequest": "Waiting for browser request...", "waitingForVerification": "Waiting for verification...", "passkey": "Passkey", + "passKeyPendingVerification": "Verification is still pending", + "loginSessionExpired" : "Session expired", + "loginSessionExpiredDetails": "Your session has expired. Please login again.", "developerSettingsWarning":"Are you sure that you want to modify Developer settings?", "developerSettings": "Developer settings", "serverEndpoint": "Server endpoint", diff --git a/auth/lib/l10n/arb/app_id.arb b/auth/lib/l10n/arb/app_id.arb new file mode 100644 index 0000000000..5cbf3ac780 --- /dev/null +++ b/auth/lib/l10n/arb/app_id.arb @@ -0,0 +1,39 @@ +{ + "setupFirstAccount": "Siapkan akun pertama kamu", + "importScanQrCode": "Pindai Kode QR", + "reportABug": "Laporkan bug", + "rateUsOnStore": "Nilai kami di {storeName}", + "blog": "Blog", + "welcomeBack": "Selamat datang kembali!", + "supportDiscount": "Gunakan kode kupon \"AUTH\" untuk mendapatkan potongan 10% pada tahun pertamamu", + "data": "Data", + "ok": "Oke", + "cancel": "Batal", + "email": "Email", + "support": "Dukungan", + "general": "Umum", + "settings": "Pengaturan", + "suggestFeatures": "Sarankan fitur", + "faq": "Tanya Jawab Umum", + "scan": "Pindai", + "scanACode": "Pindai kode", + "createNewAccount": "Buat akun baru", + "confirmPassword": "Konfirmasi sandi", + "selectLanguage": "Pilih bahasa", + "language": "Bahasa", + "social": "Sosial", + "security": "Keamanan", + "searchHint": "Cari...", + "scanAQrCode": "Pindai kode QR", + "createAccount": "Buat akun", + "password": "Sandi", + "signUpTerms": "Saya menyetujui ketentuan layanan dan kebijakan privasi Ente", + "ackPasswordLostWarning": "Saya mengerti bahwa jika saya lupa sandi saya, data saya bisa hilang karena dienkripsi secara end-to-end.", + "loginTerms": "Dengan mengklik masuk akun, saya menyetujui ketentuan layanan dan kebijakan privasi Ente", + "warning": "Peringatan", + "androidCancelButton": "Batal", + "@androidCancelButton": { + "description": "Message showed on a button that the user can click to leave the current dialog. It is used on Android side. Maximum 30 characters." + }, + "hearUsWhereTitle": "Dari mana Anda menemukan Ente? (opsional)" +} \ No newline at end of file diff --git a/auth/lib/l10n/arb/app_it.arb b/auth/lib/l10n/arb/app_it.arb index 92543ed821..579b672213 100644 --- a/auth/lib/l10n/arb/app_it.arb +++ b/auth/lib/l10n/arb/app_it.arb @@ -20,6 +20,8 @@ "codeIssuerHint": "Emittente", "codeSecretKeyHint": "Codice segreto", "codeAccountHint": "Account (username@dominio.it)", + "codeTagHint": "Tag", + "accountKeyType": "Tipo di chiave", "sessionExpired": "Sessione scaduta", "@sessionExpired": { "description": "Title of the dialog when the users current session is invalid/expired" @@ -77,16 +79,19 @@ "data": "Dati", "importCodes": "Importa codici", "importTypePlainText": "Testo in chiaro", + "importTypeEnteEncrypted": "Esportazione Ente criptata", "passwordForDecryptingExport": "Password per decriptare il file esportato", "passwordEmptyError": "La password è obbligatoria", "importFromApp": "Importa codici da {appName}", "importGoogleAuthGuide": "Esporta i tuoi account da Google Authenticator in un codice QR utilizzando l'opzione \"Trasferisci Account\". Quindi, usando un altro dispositivo, scansiona il codice QR.\n\nSuggerimento: Puoi usare la webcam del tuo computer portatile per scattare una foto del codice QR.", "importSelectJsonFile": "Seleziona file JSON", "importSelectAppExport": "Seleziona il file di esportazione {appName}", + "importEnteEncGuide": "Seleziona il file JSON criptato esportato da Ente", "importRaivoGuide": "Utilizza l'opzione \"Esporta i codici OTP in archivio Zip\" nelle impostazioni di Raivo.\n\nEstrai il file zip e importa il file JSON.", "importBitwardenGuide": "Utilizzare l'opzione \"Esporta vault\" all'interno di Bitwarden Tools e importa il file JSON non crittografato.", "importAegisGuide": "Usa l'opzione \"Esporta la cassaforte\" nelle impostazioni di Aegis.\n\nSe la tua cassaforte è criptata, dovrai inserire la password della cassaforte per decriptarla.", "import2FasGuide": "Utilizza l'opzione \"Impostazioni->Backup -Export\" in 2FAS.\n\nSe il backup è crittografato, è necessario inserire la password per decriptare il backup", + "importLastpassGuide": "Usa l'opzione \"Trasferisci account\" all'interno delle impostazioni di Lastpass Authenticator e premi \"Esporta account su file\". Importa il JSON scaricato.", "exportCodes": "Esporta codici", "importLabel": "Importa", "importInstruction": "Per favore seleziona un file contenente una lista dei tuoi codici nel seguente formato", @@ -111,18 +116,22 @@ "copied": "Copiato", "pleaseTryAgain": "Per favore riprova", "existingUser": "Accedi", + "newUser": "Nuovo utente", "delete": "Cancella", "enterYourPasswordHint": "Inserisci la tua password", "forgotPassword": "Password dimenticata", "oops": "Oops", "suggestFeatures": "Suggerisci funzionalità", "faq": "FAQ", + "faq_q_1": "Quanto è sicuro Auth?", + "faq_a_1": "Tutti i codici di cui fai il backup tramite Auth sono memorizzati con crittografia end-to-end. Ciò significa che solo tu puoi accedere ai tuoi codici. Le nostre app sono open source e la nostra crittografia è stata verificata esternamente.", "faq_q_2": "Posso accedere ai miei codici sul desktop?", "faq_a_2": "Puoi accedere ai tuoi codici sul web @ auth.ente.io.", "faq_q_3": "Come posso cancellare i codici?", "faq_a_3": "Puoi eliminare un codice scorrendo il dito a sinistra sul codice in questione.", "faq_q_4": "Come posso supportare questo progetto?", "faq_a_4": "Puoi supportare lo sviluppo di questo progetto abbonandoti alla nostra app Photos @ ente.io.", + "faq_q_5": "Come posso abilitare il blocco FaceID in Auth", "faq_a_5": "Puoi abilitare il blocco FaceID in Impostazioni → Sicurezza → Schermata di blocco.", "somethingWentWrongMessage": "Qualcosa è andato storto, per favore riprova", "leaveFamily": "Abbandona il piano famiglia", @@ -136,6 +145,8 @@ "enterCodeHint": "Inserisci il codice di 6 cifre dalla tua app di autenticazione", "lostDeviceTitle": "Dispositivo perso?", "twoFactorAuthTitle": "Autenticazione a due fattori", + "passkeyAuthTitle": "Verifica della passkey", + "verifyPasskey": "Verifica passkey", "recoverAccount": "Recupera account", "enterRecoveryKeyHint": "Inserisci la tua chiave di recupero", "recover": "Recupera", @@ -147,6 +158,7 @@ } } }, + "invalidQRCode": "Codice QR non valido", "noRecoveryKeyTitle": "Nessuna chiave di recupero?", "enterEmailHint": "Inserisci il tuo indirizzo email", "invalidEmailTitle": "Indirizzo email non valido", @@ -190,6 +202,9 @@ "doThisLater": "Fallo più tardi", "saveKey": "Salva chiave", "save": "Salva", + "send": "Invia", + "saveOrSendDescription": "Vuoi salvarlo nel tuo spazio di archiviazione (cartella Download per impostazione predefinita) o inviarlo ad altre applicazioni?", + "saveOnlyDescription": "Vuoi salvarlo nel tuo spazio di archiviazione (cartella Download per impostazione predefinita)?", "back": "Indietro", "createAccount": "Crea account", "passwordStrength": "Forza password: {passwordStrengthValue}", @@ -337,6 +352,7 @@ "deleteCodeAuthMessage": "Autenticarsi per cancellare il codice", "showQRAuthMessage": "Autenticarsi per mostrare il codice QR", "confirmAccountDeleteTitle": "Conferma l'eliminazione dell'account", + "confirmAccountDeleteMessage": "Questo account è collegato ad altre app di Ente, se ne utilizzi.\n\nI tuoi dati caricati, su tutte le app di Ente, saranno pianificati per la cancellazione e il tuo account verrà eliminato definitivamente.", "androidBiometricHint": "Verifica l'identità", "@androidBiometricHint": { "description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters." @@ -397,5 +413,28 @@ "doNotSignOut": "Non uscire", "hearUsWhereTitle": "Dove hai sentito parlare di Ente? (opzionale)", "hearUsExplanation": "Non teniamo traccia delle installazioni dell'app. Sarebbe utile se ci dicessi dove ci hai trovato!", - "passkey": "Passkey" + "recoveryKeySaved": "Chiave di recupero salvata nella cartella Download!", + "waitingForBrowserRequest": "In attesa della richiesta del browser...", + "waitingForVerification": "In attesa di verifica...", + "passkey": "Passkey", + "developerSettingsWarning": "Siete sicuri di voler modificare le impostazioni sviluppatore?", + "developerSettings": "Impostazioni sviluppatore", + "serverEndpoint": "Endpoint del server", + "invalidEndpoint": "Endpoint invalido", + "invalidEndpointMessage": "Spiacenti, l'endpoint inserito non è valido. Inserisci un endpoint valido e riprova.", + "endpointUpdatedMessage": "Endpoint aggiornato con successo", + "customEndpoint": "Connesso a {endpoint}", + "pinText": "Fissa", + "unpinText": "Sgancia", + "pinnedCodeMessage": "{code} è stato fissato", + "unpinnedCodeMessage": "{code} è stato sganciato", + "tags": "Tag", + "createNewTag": "Crea un nuovo tag", + "tag": "Tag", + "create": "Crea", + "editTag": "Modifica tag", + "deleteTagTitle": "Eliminare il tag?", + "deleteTagMessage": "Sei sicuro di voler eliminare questo tag? Questa azione è irreversibile.", + "somethingWentWrongParsingCode": "Non siamo riusciti ad analizzare i codici {x}.", + "updateNotAvailable": "Aggiornamento non disponibile" } \ No newline at end of file diff --git a/auth/lib/l10n/arb/app_nl.arb b/auth/lib/l10n/arb/app_nl.arb index 36280f69dc..f4360bd922 100644 --- a/auth/lib/l10n/arb/app_nl.arb +++ b/auth/lib/l10n/arb/app_nl.arb @@ -20,6 +20,8 @@ "codeIssuerHint": "Uitgever", "codeSecretKeyHint": "Geheime sleutel", "codeAccountHint": "Account (jij@domein.nl)", + "codeTagHint": "Label", + "accountKeyType": "Type sleutel", "sessionExpired": "Sessie verlopen", "@sessionExpired": { "description": "Title of the dialog when the users current session is invalid/expired" @@ -156,6 +158,7 @@ } } }, + "invalidQRCode": "Ongeldige QR-code", "noRecoveryKeyTitle": "Geen herstelsleutel?", "enterEmailHint": "Voer je e-mailadres in", "invalidEmailTitle": "Ongeldig e-mailadres", @@ -420,5 +423,18 @@ "invalidEndpoint": "Ongeldig eindpunt", "invalidEndpointMessage": "Sorry, het eindpunt dat u hebt ingevoerd is ongeldig. Voer een geldig eindpunt in en probeer het opnieuw.", "endpointUpdatedMessage": "Eindpunt met succes bijgewerkt", - "customEndpoint": "Verbonden met {endpoint}" + "customEndpoint": "Verbonden met {endpoint}", + "pinText": "Vastzetten", + "unpinText": "Losmaken", + "pinnedCodeMessage": "{code} is vastgezet", + "unpinnedCodeMessage": "{code} is losgemaakt", + "tags": "Labels", + "createNewTag": "Nieuw label maken", + "tag": "Label", + "create": "Creëren", + "editTag": "Bewerk label", + "deleteTagTitle": "Label verwijderen?", + "deleteTagMessage": "Weet je zeker dat je deze label wilt verwijderen? Deze actie is onomkeerbaar.", + "somethingWentWrongParsingCode": "We konden {x} codes niet verwerken.", + "updateNotAvailable": "Update niet beschikbaar" } \ No newline at end of file diff --git a/auth/lib/l10n/arb/app_pt.arb b/auth/lib/l10n/arb/app_pt.arb index 52960987f6..0e336e19d8 100644 --- a/auth/lib/l10n/arb/app_pt.arb +++ b/auth/lib/l10n/arb/app_pt.arb @@ -19,7 +19,7 @@ "pleaseVerifyDetails": "Por favor, verifique os detalhes e tente novamente", "codeIssuerHint": "Emissor", "codeSecretKeyHint": "Chave secreta", - "codeAccountHint": "Conta (voce@dominio.com)", + "codeAccountHint": "Conta (você@domínio.com)", "codeTagHint": "Etiqueta", "accountKeyType": "Tipo de chave", "sessionExpired": "Sessão expirada", @@ -27,7 +27,7 @@ "description": "Title of the dialog when the users current session is invalid/expired" }, "pleaseLoginAgain": "Por favor, faça login novamente", - "loggingOut": "Desconectando...", + "loggingOut": "Saindo...", "timeBasedKeyType": "Baseado no horário (TOTP)", "counterBasedKeyType": "Baseado em um contador (HOTP)", "saveAction": "Salvar", @@ -51,7 +51,7 @@ "reportABug": "Informar um problema", "crashAndErrorReporting": "Reporte de erros e falhas", "reportBug": "Informar problema", - "emailUsMessage": "Por favor, envie um e-mail para {email}", + "emailUsMessage": "Envie um e-mail para {email}", "@emailUsMessage": { "placeholders": { "email": { @@ -59,12 +59,12 @@ } } }, - "contactSupport": "Falar com o suporte", + "contactSupport": "Falar com o Suporte", "rateUsOnStore": "Avalie-nos na {storeName}", "blog": "Blog", "merchandise": "Produtos", "verifyPassword": "Verificar senha", - "pleaseWait": "Por favor, aguarde...", + "pleaseWait": "Aguarde...", "generatingEncryptionKeysTitle": "Gerando chaves de criptografia...", "recreatePassword": "Recriar senha", "recreatePasswordMessage": "O dispositivo atual não é poderoso o suficiente para verificar sua senha, mas podemos regenerar de uma forma que funcione com todos os dispositivos.\n\nPor favor, faça o login usando sua chave de recuperação e recrie sua senha (você pode usar o mesmo novamente se desejar).", @@ -81,10 +81,10 @@ "importTypePlainText": "Texto simples", "importTypeEnteEncrypted": "Exportação Ente criptografada", "passwordForDecryptingExport": "Senha para descriptografar a exportação", - "passwordEmptyError": "O campo senha não pode estar vazio", + "passwordEmptyError": "A senha não pode estar vazia", "importFromApp": "Importar códigos do {appName}", "importGoogleAuthGuide": "Exporte suas contas do Google Authenticator para um QR code usando a opção \"Transferir contas\". Então, usando outro dispositivo, escaneie o QR code.\n\nDica: Você pode usar a câmera do seu notebook para fotografar o QR code.", - "importSelectJsonFile": "Selecione o arquivo JSON", + "importSelectJsonFile": "Selecionar arquivo JSON", "importSelectAppExport": "Selecione o arquivo de exportação do aplicativo {appName}", "importEnteEncGuide": "Selecione o arquivo JSON criptografado exportado do Ente", "importRaivoGuide": "Use a opção \"Exportar OTPs para arquivo Zip\" nas configurações do Raivo.\n\nExtraia o arquivo zip e importe o arquivo JSON.", @@ -92,7 +92,7 @@ "importAegisGuide": "Use a opção \"Exportar cofre\" nas Configurações do Aegis.\n\nSe o seu cofre estiver criptografado, você precisará inserir a senha do cofre para descriptografá-lo.", "import2FasGuide": "Use a opção \"Configurações->Exportar cópia de segurança\" no aplicativo 2FAS.\n\nSe a cópia de segurança estiver criptografada, será necessário inserir a senha para descriptografá-la", "importLastpassGuide": "Use a opção \"Transferir contas\" nas configurações do LastPass Authenticator e pressione \"Exportar contas para arquivo\". Importe o arquivo JSON baixado.", - "exportCodes": "Exportar Códigos", + "exportCodes": "Exportar códigos", "importLabel": "Importar", "importInstruction": "Por favor, selecione um arquivo que contenha uma lista de códigos no seguinte formato", "importCodeDelimiterInfo": "Os códigos podem ser separados por uma vírgula ou uma nova linha", @@ -114,14 +114,14 @@ "general": "Geral", "settings": "Ajustes", "copied": "Copiado", - "pleaseTryAgain": "Por favor, tente novamente", - "existingUser": "Usuário Existente", + "pleaseTryAgain": "Tente de novo", + "existingUser": "Usuário existente", "newUser": "Novo no Ente", "delete": "Excluir", "enterYourPasswordHint": "Insira sua senha", "forgotPassword": "Esqueci a senha", "oops": "Opa", - "suggestFeatures": "Sugerir funcionalidades", + "suggestFeatures": "Sugerir recursos", "faq": "Perguntas frequentes", "faq_q_1": "Quão seguro é o Auth?", "faq_a_1": "Todos os códigos que você faz backup via Auth são armazenados criptografados de ponta a ponta. Isso significa que somente você pode acessar seus códigos. Nossos aplicativos são de código aberto e nossa criptografia foi auditada externamente.", @@ -143,12 +143,12 @@ "verify": "Verificar", "verifyEmail": "Verificar e-mail", "enterCodeHint": "Digite o código de 6 dígitos de\nseu aplicativo autenticador", - "lostDeviceTitle": "Perdeu seu dispositivo?", + "lostDeviceTitle": "Perdeu um dispositivo?", "twoFactorAuthTitle": "Autenticação de dois fatores", "passkeyAuthTitle": "Autenticação via Chave de acesso", - "verifyPasskey": "Verificar chave de acesso", + "verifyPasskey": "Verificar senha-mestra", "recoverAccount": "Recuperar conta", - "enterRecoveryKeyHint": "Digite sua chave de recuperação", + "enterRecoveryKeyHint": "Digite a chave de recuperação", "recover": "Recuperar", "contactSupportViaEmailMessage": "Por favor, envie um e-mail para {email} a partir do seu endereço de e-mail registrado", "@contactSupportViaEmailMessage": { @@ -160,7 +160,7 @@ }, "invalidQRCode": "QR Code inválido", "noRecoveryKeyTitle": "Sem chave de recuperação?", - "enterEmailHint": "Insira o seu endereço de e-mail", + "enterEmailHint": "Insira o endereço de e-mail", "invalidEmailTitle": "Endereço de e-mail inválido", "invalidEmailMessage": "Por favor, insira um endereço de e-mail válido.", "deleteAccount": "Excluir conta", @@ -175,8 +175,8 @@ "moderateStrength": "Moderada", "confirmPassword": "Confirme sua senha", "close": "Fechar", - "oopsSomethingWentWrong": "Oops, Algo deu errado.", - "selectLanguage": "Selecionar idioma", + "oopsSomethingWentWrong": "Opa. Algo deu errado.", + "selectLanguage": "Trocar idioma", "language": "Idioma", "social": "Redes sociais", "security": "Segurança", @@ -199,14 +199,14 @@ "recoveryKeyCopiedToClipboard": "A chave de recuperação foi copiada para a área de transferência", "recoveryKeyOnForgotPassword": "Caso você esqueça sua senha, a única maneira de recuperar seus dados é com essa chave.", "recoveryKeySaveDescription": "Não armazenamos essa chave, por favor, salve essa chave de 24 palavras em um lugar seguro.", - "doThisLater": "Fazer isso mais tarde", + "doThisLater": "Fazer isso depois", "saveKey": "Salvar chave", "save": "Salvar", "send": "Enviar", "saveOrSendDescription": "Você deseja salvar isso no seu armazenamento (pasta de downloads por padrão) ou enviá-lo para outros aplicativos?", "saveOnlyDescription": "Você deseja salvar isto no seu armazenamento (pasta de downloads por padrão)?", "back": "Voltar", - "createAccount": "Criar uma conta", + "createAccount": "Criar conta", "passwordStrength": "Força da senha: {passwordStrengthValue}", "@passwordStrength": { "description": "Text to indicate the password strength", @@ -234,7 +234,7 @@ "passwordChangedSuccessfully": "Senha alterada com sucesso", "generatingEncryptionKeys": "Gerando chaves de criptografia...", "continueLabel": "Continuar", - "insecureDevice": "Dispositivo não seguro", + "insecureDevice": "Dispositivo inseguro", "sorryWeCouldNotGenerateSecureKeysOnThisDevicennplease": "Desculpe, não foi possível gerar chaves seguras neste dispositivo.\n\npor favor, faça o login com um dispositivo diferente.", "howItWorks": "Como funciona", "ackPasswordLostWarning": "Eu entendo que se eu perder minha senha, posso perder meus dados, já que meus dados são criptografados de ponta a ponta.", @@ -257,11 +257,11 @@ "recoveryKeyVerifyReason": "Sua chave de recuperação é a única maneira de recuperar suas fotos se você esquecer sua senha. Você pode encontrar sua chave de recuperação em Configurações > Conta.\n\nDigite sua chave de recuperação aqui para verificar se você a salvou corretamente.", "confirmYourRecoveryKey": "Confirme sua chave de recuperação", "confirm": "Confirmar", - "emailYourLogs": "Enviar por email seus logs", + "emailYourLogs": "Enviar logs por e-mail", "pleaseSendTheLogsTo": "Por favor, envie os logs para \n{toEmail}", "copyEmailAddress": "Copiar endereço de e-mail", "exportLogs": "Exportar logs", - "enterYourRecoveryKey": "Digite sua chave de recuperação", + "enterYourRecoveryKey": "Digite a chave de recuperação", "tempErrorContactSupportIfPersists": "Parece que algo deu errado. Por favor, tente novamente mais tarde. Se o erro persistir, entre em contato com nossa equipe de suporte.", "itLooksLikeSomethingWentWrongPleaseRetryAfterSome": "Parece que algo deu errado. Por favor, tente novamente mais tarde. Se o erro persistir, entre em contato com nossa equipe de suporte.", "about": "Sobre", @@ -277,7 +277,7 @@ "youAreOnTheLatestVersion": "Você está na versão mais recente", "warning": "Atenção", "exportWarningDesc": "O arquivo exportado contém informações confidenciais. Por favor, armazene-o com segurança.", - "iUnderStand": "Eu entendo", + "iUnderStand": "Entendo", "@iUnderStand": { "description": "Text for the button to confirm the user understands the warning" }, @@ -326,11 +326,11 @@ "sorryTheCodeYouveEnteredIsIncorrect": "Desculpe, o código que você inseriu está incorreto", "emailChangedTo": "E-mail alterado para {newEmail}", "authenticationFailedPleaseTryAgain": "Falha na autenticação. Por favor, tente novamente", - "authenticationSuccessful": "Autenticação bem-sucedida!", + "authenticationSuccessful": "Autenticado!", "twofactorAuthenticationSuccessfullyReset": "Autenticação de dois fatores redefinida com sucesso", "incorrectRecoveryKey": "Chave de recuperação incorreta", "theRecoveryKeyYouEnteredIsIncorrect": "A chave de recuperação inserida está incorreta", - "enterPassword": "Insira a senha", + "enterPassword": "Inserir senha", "selectExportFormat": "Selecione o formato para exportação", "exportDialogDesc": "As exportações criptografadas ficarão protegidas por uma senha de sua escolha.", "encrypted": "Criptografado", @@ -345,7 +345,7 @@ "showLargeIcons": "Mostrar ícones grandes", "shouldHideCode": "Ocultar códigos", "doubleTapToViewHiddenCode": "Você pode tocar duas vezes em uma entrada para ver o código", - "focusOnSearchBar": "Foco na pesquisa ao iniciar o aplicativo", + "focusOnSearchBar": "Foco na busca ao iniciar o app", "confirmUpdatingkey": "Você tem certeza que deseja atualizar a chave secreta?", "minimizeAppOnCopy": "Minimizar aplicativo ao copiar", "editCodeAuthMessage": "Autenticar para editar o código", @@ -357,7 +357,7 @@ "@androidBiometricHint": { "description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters." }, - "androidBiometricNotRecognized": "Não reconhecido. Tente novamente.", + "androidBiometricNotRecognized": "Não reconhecido. Tente de novo.", "@androidBiometricNotRecognized": { "description": "Message to let the user know that authentication was failed. It is used on Android side. Maximum 60 characters." }, @@ -385,7 +385,7 @@ "@androidDeviceCredentialsSetupDescription": { "description": "Message advising the user to go to the settings and configure device credentials on their device. It shows in a dialog on Android side." }, - "goToSettings": "Ir para Configurações", + "goToSettings": "Ir para Ajustes", "@goToSettings": { "description": "Message showed on a button that the user can click to go to settings pages from the current dialog. It is used on both Android and iOS side. Maximum 30 characters." }, @@ -410,13 +410,13 @@ "signOutFromOtherDevices": "Terminar sessão em outros dispositivos", "signOutOtherBody": "Se você acha que alguém pode saber sua senha, você pode forçar todos os outros dispositivos que estão com sua conta a desconectar.", "signOutOtherDevices": "Terminar sessão em outros dispositivos", - "doNotSignOut": "Não encerrar sessão", + "doNotSignOut": "Não sair", "hearUsWhereTitle": "Como você ouviu sobre o Ente? (opcional)", "hearUsExplanation": "Não rastreamos instalações do aplicativo. Seria útil se você nos contasse onde nos encontrou!", "recoveryKeySaved": "Chave de recuperação salva na pasta Downloads!", "waitingForBrowserRequest": "Aguardando solicitação do navegador...", "waitingForVerification": "Esperando por verificação...", - "passkey": "Chave de acesso", + "passkey": "Senha-mestra", "developerSettingsWarning": "Tem certeza de que deseja modificar as configurações de Desenvolvedor?", "developerSettings": "Configurações de desenvolvedor", "serverEndpoint": "Endpoint do servidor", @@ -429,7 +429,7 @@ "pinnedCodeMessage": "{code} foi fixado", "unpinnedCodeMessage": "{code} foi desafixado", "tags": "Etiquetas", - "createNewTag": "Criar etiqueta", + "createNewTag": "Criar nova etiqueta", "tag": "Etiqueta", "create": "Criar", "editTag": "Editar etiqueta", diff --git a/auth/lib/l10n/arb/app_te.arb b/auth/lib/l10n/arb/app_te.arb new file mode 100644 index 0000000000..9e26dfeeb6 --- /dev/null +++ b/auth/lib/l10n/arb/app_te.arb @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/auth/lib/main.dart b/auth/lib/main.dart index 9fa2841ff0..18a8c3e1ae 100644 --- a/auth/lib/main.dart +++ b/auth/lib/main.dart @@ -66,8 +66,6 @@ Future initSystemTray() async { void main() async { WidgetsFlutterBinding.ensureInitialized(); - initSystemTray().ignore(); - if (PlatformUtil.isDesktop()) { await windowManager.ensureInitialized(); await WindowListenerService.instance.init(); @@ -77,8 +75,10 @@ void main() async { await windowManager.waitUntilReadyToShow(windowOptions, () async { await windowManager.show(); await windowManager.focus(); + initSystemTray().ignore(); }); } + await _runInForeground(); await _setupPrivacyScreen(); if (Platform.isAndroid) { @@ -132,7 +132,7 @@ Future _runWithLogs(Function() function, {String prefix = ""}) async { } void _registerWindowsProtocol() { - const kWindowsScheme = 'ente'; + const kWindowsScheme = 'enteauth'; // Register our protocol only on Windows platform if (!kIsWeb && Platform.isWindows) { WindowsProtocolHandler() diff --git a/auth/lib/services/passkey_service.dart b/auth/lib/services/passkey_service.dart index 825a19729b..2be2bb1a02 100644 --- a/auth/lib/services/passkey_service.dart +++ b/auth/lib/services/passkey_service.dart @@ -42,7 +42,7 @@ class PasskeyService { Future openPasskeyPage(BuildContext context) async { try { final jwtToken = await getJwtToken(); - final url = "https://accounts.ente.io/account-handoff?token=$jwtToken"; + final url = "https://accounts.ente.io/passkeys?token=$jwtToken"; await launchUrlString( url, mode: LaunchMode.externalApplication, diff --git a/auth/lib/services/user_service.dart b/auth/lib/services/user_service.dart index 9294680598..d36196a541 100644 --- a/auth/lib/services/user_service.dart +++ b/auth/lib/services/user_service.dart @@ -266,32 +266,77 @@ class UserService { } } - Future onPassKeyVerified(BuildContext context, Map response) async { - final userPassword = Configuration.instance.getVolatilePassword(); - if (userPassword == null) throw Exception("volatile password is null"); - - await _saveConfiguration(response); - - Widget page; - if (Configuration.instance.getEncryptedToken() != null) { - await Configuration.instance.decryptSecretsAndGetKeyEncKey( - userPassword, - Configuration.instance.getKeyAttributes()!, - ); - page = const HomePage(); - } else { - throw Exception("unexpected response during passkey verification"); - } - - // ignore: unawaited_futures - Navigator.of(context).pushAndRemoveUntil( - MaterialPageRoute( - builder: (BuildContext context) { - return page; + Future getTokenForPasskeySession(String sessionID) async { + try { + final response = await _dio.get( + "${_config.getHttpEndpoint()}/users/two-factor/passkeys/get-token", + queryParameters: { + "sessionID": sessionID, }, - ), - (route) => route.isFirst, - ); + ); + return response.data; + } on DioException catch (e) { + if (e.response != null) { + if (e.response!.statusCode == 404 || e.response!.statusCode == 410) { + throw PassKeySessionExpiredError(); + } + if (e.response!.statusCode == 400) { + throw PassKeySessionNotVerifiedError(); + } + } + rethrow; + } catch (e, s) { + _logger.severe("unexpected error", e, s); + rethrow; + } + } + + Future onPassKeyVerified(BuildContext context, Map response) async { + final ProgressDialog dialog = + createProgressDialog(context, context.l10n.pleaseWait); + await dialog.show(); + try { + final userPassword = _config.getVolatilePassword(); + await _saveConfiguration(response); + if (userPassword == null) { + await dialog.hide(); + // ignore: unawaited_futures + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (BuildContext context) { + return const PasswordReentryPage(); + }, + ), + (route) => route.isFirst, + ); + } else { + Widget page; + if (_config.getEncryptedToken() != null) { + await _config.decryptSecretsAndGetKeyEncKey( + userPassword, + _config.getKeyAttributes()!, + ); + page = const HomePage(); + } else { + throw Exception("unexpected response during passkey verification"); + } + await dialog.hide(); + + // ignore: unawaited_futures + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (BuildContext context) { + return page; + }, + ), + (route) => route.isFirst, + ); + } + } catch (e) { + _logger.severe(e); + await dialog.hide(); + rethrow; + } } Future verifyEmail( @@ -316,9 +361,12 @@ class UserService { await dialog.hide(); if (response.statusCode == 200) { Widget page; + final String passkeySessionID = response.data["passkeySessionID"]; final String twoFASessionID = response.data["twoFactorSessionID"]; if (twoFASessionID.isNotEmpty) { page = TwoFactorAuthenticationPage(twoFASessionID); + } else if (passkeySessionID.isNotEmpty) { + page = PasskeyPage(passkeySessionID); } else { await _saveConfiguration(response); if (Configuration.instance.getEncryptedToken() != null) { diff --git a/auth/lib/store/code_display_store.dart b/auth/lib/store/code_display_store.dart index 74972f5a22..d6afd1b494 100644 --- a/auth/lib/store/code_display_store.dart +++ b/auth/lib/store/code_display_store.dart @@ -32,7 +32,7 @@ class CodeDisplayStore { if (code.hasError) continue; tags.addAll(code.display.tags); } - return tags.toList(); + return tags.toList()..sort(); } Future showDeleteTagDialog(BuildContext context, String tag) async { diff --git a/auth/lib/ui/components/models/button_type.dart b/auth/lib/ui/components/models/button_type.dart index 8b9647c07f..2122769d63 100644 --- a/auth/lib/ui/components/models/button_type.dart +++ b/auth/lib/ui/components/models/button_type.dart @@ -33,7 +33,7 @@ enum ButtonType { Color defaultButtonColor(EnteColorScheme colorScheme) { if (isPrimary) { - return colorScheme.primary500; + return colorScheme.primary400; } if (isSecondary) { return colorScheme.fillFaint; diff --git a/auth/lib/ui/home_page.dart b/auth/lib/ui/home_page.dart index 4110a5f88e..81fd7ed586 100644 --- a/auth/lib/ui/home_page.dart +++ b/auth/lib/ui/home_page.dart @@ -238,6 +238,8 @@ class _HomePageState extends State { title: !_showSearchBox ? const Text('Ente Auth') : TextField( + autocorrect: false, + enableSuggestions: false, focusNode: searchInputFocusNode, autofocus: _searchText.isEmpty, controller: _textController, diff --git a/auth/lib/ui/passkey_page.dart b/auth/lib/ui/passkey_page.dart index 8c2e54e980..eab0b72f99 100644 --- a/auth/lib/ui/passkey_page.dart +++ b/auth/lib/ui/passkey_page.dart @@ -2,12 +2,14 @@ import 'dart:convert'; import 'package:app_links/app_links.dart'; import 'package:ente_auth/core/configuration.dart'; +import 'package:ente_auth/core/errors.dart'; import 'package:ente_auth/l10n/l10n.dart'; import 'package:ente_auth/models/account/two_factor.dart'; import 'package:ente_auth/services/user_service.dart'; import 'package:ente_auth/ui/components/buttons/button_widget.dart'; import 'package:ente_auth/ui/components/models/button_type.dart'; import 'package:ente_auth/utils/dialog_util.dart'; +import 'package:ente_auth/utils/toast_util.dart'; import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; import 'package:url_launcher/url_launcher_string.dart'; @@ -41,13 +43,38 @@ class _PasskeyPageState extends State { Future launchPasskey() async { await launchUrlString( - "https://accounts.ente.io/passkeys/flow?" + "https://accounts.ente.io/passkeys/verify?" "passkeySessionID=${widget.sessionID}" - "&redirect=enteauth://passkey", + "&redirect=enteauth://passkey" + "&clientPackage=io.ente.auth", mode: LaunchMode.externalApplication, ); } + Future checkStatus() async { + late dynamic response; + try { + response = await UserService.instance + .getTokenForPasskeySession(widget.sessionID); + } on PassKeySessionNotVerifiedError { + showToast(context, context.l10n.passKeyPendingVerification); + return; + } on PassKeySessionExpiredError { + await showErrorDialog( + context, + context.l10n.loginSessionExpired, + context.l10n.loginSessionExpiredDetails, + ); + Navigator.of(context).pop(); + return; + } catch (e, s) { + _logger.severe("failed to check status", e, s); + showGenericErrorDialog(context: context).ignore(); + return; + } + await UserService.instance.onPassKeyVerified(context, response); + } + Future _handleDeeplink(String? link) async { if (!context.mounted || Configuration.instance.hasConfiguredAccount() || @@ -59,8 +86,20 @@ class _PasskeyPageState extends State { } try { if (mounted && link.toLowerCase().startsWith("enteauth://passkey")) { - final String? uri = Uri.parse(link).queryParameters['response']; - String base64String = uri!.toString(); + if (Configuration.instance.isLoggedIn()) { + _logger.info('ignored deeplink: already configured'); + showToast(context, 'Account is already configured.'); + return; + } + final parsedUri = Uri.parse(link); + final sessionID = parsedUri.queryParameters['passkeySessionID']; + if (sessionID != widget.sessionID) { + showToast(context, "Session ID mismatch"); + _logger.warning('ignored deeplink: sessionID mismatch'); + return; + } + final String? authResponse = parsedUri.queryParameters['response']; + String base64String = authResponse!.toString(); while (base64String.length % 4 != 0) { base64String += '='; } @@ -118,9 +157,23 @@ class _PasskeyPageState extends State { const SizedBox(height: 16), ButtonWidget( buttonType: ButtonType.primary, - labelText: context.l10n.verifyPasskey, + labelText: context.l10n.tryAgain, onTap: () => launchPasskey(), ), + const SizedBox(height: 16), + ButtonWidget( + buttonType: ButtonType.secondary, + labelText: context.l10n.checkStatus, + onTap: () async { + try { + await checkStatus(); + } catch (e) { + debugPrint('failed to check status %e'); + showGenericErrorDialog(context: context).ignore(); + } + }, + shouldSurfaceExecutionStates: true, + ), const Padding(padding: EdgeInsets.all(30)), GestureDetector( behavior: HitTestBehavior.opaque, diff --git a/auth/lib/ui/settings/data/import/aegis_import.dart b/auth/lib/ui/settings/data/import/aegis_import.dart index f6dd872522..471ce943ce 100644 --- a/auth/lib/ui/settings/data/import/aegis_import.dart +++ b/auth/lib/ui/settings/data/import/aegis_import.dart @@ -6,6 +6,7 @@ import 'dart:typed_data'; import 'package:convert/convert.dart'; import 'package:ente_auth/l10n/l10n.dart'; import 'package:ente_auth/models/code.dart'; +import 'package:ente_auth/models/code_display.dart'; import 'package:ente_auth/services/authenticator_service.dart'; import 'package:ente_auth/store/code_store.dart'; import 'package:ente_auth/ui/common/progress_dialog.dart'; @@ -76,7 +77,7 @@ Future _pickAegisJsonFile(BuildContext context) async { await showErrorDialog( context, context.l10n.sorry, - context.l10n.importFailureDesc, + "${context.l10n.importFailureDesc}\n Error: ${e.toString()}", ); } } @@ -126,8 +127,19 @@ Future _processAegisExportFile( } else { aegisDB = decodedJson['db']; } + final Map groupIDToName = {}; + try { + for (var item in aegisDB?['groups']) { + groupIDToName[item['uuid']] = item['name']; + } + } catch (e) { + Logger("AegisImport").warning("Failed to parse groups", e); + } + final parsedCodes = []; for (var item in aegisDB?['entries']) { + bool isFavorite = item['favorite'] ?? false; + List tags = []; var kind = item['type']; var account = item['name']; var issuer = item['issuer']; @@ -137,20 +149,27 @@ Future _processAegisExportFile( var digits = item['info']['digits']; var counter = item['info']['counter']; - + for (var group in item['groups']) { + if (groupIDToName.containsKey(group)) { + tags.add(groupIDToName[group]!); + } + } // Build the OTP URL String otpUrl; - if (kind.toLowerCase() == 'totp') { + if (kind.toLowerCase() == 'totp' || kind.toLowerCase() == 'steam') { otpUrl = 'otpauth://$kind/$issuer:$account?secret=$secret&issuer=$issuer&algorithm=$algorithm&digits=$digits&period=$timer'; } else if (kind.toLowerCase() == 'hotp') { otpUrl = 'otpauth://$kind/$issuer:$account?secret=$secret&issuer=$issuer&algorithm=$algorithm&digits=$digits&counter=$counter'; } else { - throw Exception('Invalid OTP type'); + throw Exception('Invalid OTP type: $kind'); } - parsedCodes.add(Code.fromOTPAuthUrl(otpUrl)); + + Code code = Code.fromOTPAuthUrl(otpUrl); + code = code.copyWith(display: CodeDisplay(pinned: isFavorite, tags: tags)); + parsedCodes.add(code); } for (final code in parsedCodes) { diff --git a/auth/lib/ui/settings/data/import/bitwarden_import.dart b/auth/lib/ui/settings/data/import/bitwarden_import.dart index 6878fa9f05..eec93dbdb4 100644 --- a/auth/lib/ui/settings/data/import/bitwarden_import.dart +++ b/auth/lib/ui/settings/data/import/bitwarden_import.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:ente_auth/l10n/l10n.dart'; import 'package:ente_auth/models/code.dart'; +import 'package:ente_auth/models/code_display.dart'; import 'package:ente_auth/services/authenticator_service.dart'; import 'package:ente_auth/store/code_store.dart'; import 'package:ente_auth/ui/components/buttons/button_widget.dart'; @@ -14,6 +15,7 @@ import 'package:ente_auth/utils/dialog_util.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; Future showBitwardenImportInstruction(BuildContext context) async { final l10n = context.l10n; @@ -60,12 +62,13 @@ Future _pickBitwardenJsonFile(BuildContext context) async { if (count != null) { await importSuccessDialog(context, count); } - } catch (e) { + } catch (e, s) { + Logger("BitwardenImport").severe('Failed to import', e, s); await progressDialog.hide(); await showErrorDialog( context, context.l10n.sorry, - context.l10n.importFailureDesc, + "${context.l10n.importFailureDesc}\n Error: ${e.toString()}", ); } } @@ -78,19 +81,36 @@ Future _processBitwardenExportFile( final jsonString = await file.readAsString(); final data = jsonDecode(jsonString); List jsonArray = data['items']; + final Map folderIdToName = {}; + try { + for (var item in data['folders']) { + folderIdToName[item['id']] = item['name']; + } + } catch (e) { + debugPrint("Failed to get folder details $e"); + } final parsedCodes = []; for (var item in jsonArray) { if (item['login'] != null && item['login']['totp'] != null) { var totp = item['login']['totp']; + String? folderID = item['folderId']; Code code; - if (totp.contains("otpauth://")) { code = Code.fromOTPAuthUrl(totp); + } else if (totp.contains("steam://")) { + var secret = totp.split("steam://")[1]; + code = Code.fromAccountAndSecret( + Type.steam, + item['login']['username'], + item['name'], + secret, + null, + Code.steamDigits, + ); } else { - var issuer = item['name']; - var account = item['login']['username']; - + var issuer = item['name'] ?? ''; + var account = item['login']['username'] ?? ''; code = Code.fromAccountAndSecret( Type.totp, account, @@ -100,6 +120,11 @@ Future _processBitwardenExportFile( Code.defaultDigits, ); } + if (folderID != null && folderIdToName.containsKey(folderID)) { + code = code.copyWith( + display: CodeDisplay(tags: [folderIdToName[folderID]!]), + ); + } parsedCodes.add(code); } diff --git a/auth/lib/ui/settings/data/import/lastpass_import.dart b/auth/lib/ui/settings/data/import/lastpass_import.dart index 8c36f02536..550f2af7e0 100644 --- a/auth/lib/ui/settings/data/import/lastpass_import.dart +++ b/auth/lib/ui/settings/data/import/lastpass_import.dart @@ -14,6 +14,7 @@ import 'package:ente_auth/utils/dialog_util.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; Future showLastpassImportInstruction(BuildContext context) async { final l10n = context.l10n; @@ -60,12 +61,13 @@ Future _pickLastpassJsonFile(BuildContext context) async { if (count != null) { await importSuccessDialog(context, count); } - } catch (e) { + } catch (e, s) { + Logger('LastPassImport').severe('exception while processing import', e, s); await progressDialog.hide(); await showErrorDialog( context, context.l10n.sorry, - context.l10n.importFailureDesc, + "${context.l10n.importFailureDesc}\n Error: ${e.toString()}", ); } } diff --git a/auth/lib/ui/settings/data/import/raivo_plain_text_import.dart b/auth/lib/ui/settings/data/import/raivo_plain_text_import.dart index 3590a38b37..1b00086fe7 100644 --- a/auth/lib/ui/settings/data/import/raivo_plain_text_import.dart +++ b/auth/lib/ui/settings/data/import/raivo_plain_text_import.dart @@ -14,6 +14,7 @@ import 'package:ente_auth/utils/dialog_util.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; Future showRaivoImportInstruction(BuildContext context) async { final l10n = context.l10n; @@ -60,12 +61,13 @@ Future _pickRaivoJsonFile(BuildContext context) async { if (count != null) { await importSuccessDialog(context, count); } - } catch (e) { + } catch (e, s) { + Logger("RaivoImport").severe('Failed to import', e, s); await progressDialog.hide(); await showErrorDialog( context, context.l10n.sorry, - context.l10n.importFailureDesc, + "${context.l10n.importFailureDesc}\n Error: ${e.toString()}", ); } } @@ -103,7 +105,7 @@ Future _processRaivoExportFile(BuildContext context, String path) async { otpUrl = 'otpauth://$kind/$issuer:$account?secret=$secret&issuer=$issuer&algorithm=$algorithm&digits=$digits&counter=$counter'; } else { - throw Exception('Invalid OTP type'); + throw Exception('Invalid OTP type $kind'); } parsedCodes.add(Code.fromOTPAuthUrl(otpUrl)); } diff --git a/auth/lib/ui/settings/data/import/two_fas_import.dart b/auth/lib/ui/settings/data/import/two_fas_import.dart index 710d898d44..dcec016d49 100644 --- a/auth/lib/ui/settings/data/import/two_fas_import.dart +++ b/auth/lib/ui/settings/data/import/two_fas_import.dart @@ -72,7 +72,7 @@ Future _pick2FasFile(BuildContext context) async { await showErrorDialog( context, context.l10n.sorry, - context.l10n.importFailureDesc, + "${context.l10n.importFailureDesc}\n Error: ${e.toString()}", ); } } @@ -139,7 +139,10 @@ Future _process2FasExportFile( for (var item in decodedServices) { var kind = item['otp']['tokenType']; var account = item['otp']['account'] ?? ''; - var issuer = item['otp']['issuer'] ?? item['name'] ?? ''; + var issuer = item['otp']['issuer']; + if (issuer == null || (issuer as String).isEmpty) { + issuer = item['name'] ?? ''; + } var algorithm = item['otp']['algorithm']; var secret = item['secret']; var timer = item['otp']['period']; diff --git a/auth/lib/ui/settings/security_section_widget.dart b/auth/lib/ui/settings/security_section_widget.dart index 4e50d38fbd..49672b0368 100644 --- a/auth/lib/ui/settings/security_section_widget.dart +++ b/auth/lib/ui/settings/security_section_widget.dart @@ -4,7 +4,6 @@ import 'dart:typed_data'; import 'package:ente_auth/core/configuration.dart'; import 'package:ente_auth/l10n/l10n.dart'; import 'package:ente_auth/models/user_details.dart'; -import 'package:ente_auth/services/auth_feature_flag.dart'; import 'package:ente_auth/services/local_authentication_service.dart'; import 'package:ente_auth/services/passkey_service.dart'; import 'package:ente_auth/services/user_service.dart'; @@ -66,20 +65,17 @@ class _SecuritySectionWidgetState extends State { // We don't know if the user can disable MFA yet, so we fetch the info UserService.instance.getUserDetailsV2().ignore(); } - final bool isInternalUser = - FeatureFlagService.instance.isInternalUserOrDebugBuild(); children.addAll([ - if (isInternalUser) sectionOptionSpacing, - if (isInternalUser) - MenuItemWidget( - captionedTextWidget: CaptionedTextWidget( - title: l10n.passkey, - ), - pressedColor: getEnteColorScheme(context).fillFaint, - trailingIcon: Icons.chevron_right_outlined, - trailingIconIsMuted: true, - onTap: () async => await onPasskeyClick(context), + sectionOptionSpacing, + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: l10n.passkey, ), + pressedColor: getEnteColorScheme(context).fillFaint, + trailingIcon: Icons.chevron_right_outlined, + trailingIconIsMuted: true, + onTap: () async => await onPasskeyClick(context), + ), sectionOptionSpacing, MenuItemWidget( captionedTextWidget: CaptionedTextWidget( diff --git a/auth/lib/utils/totp_util.dart b/auth/lib/utils/totp_util.dart index 61c7f20e92..f886215081 100644 --- a/auth/lib/utils/totp_util.dart +++ b/auth/lib/utils/totp_util.dart @@ -4,7 +4,7 @@ import 'package:otp/otp.dart' as otp; import 'package:steam_totp/steam_totp.dart'; String getOTP(Code code) { - if (code.type == Type.steam) { + if (code.type == Type.steam || code.issuer.toLowerCase() == 'steam') { return _getSteamCode(code); } if (code.type == Type.hotp) { @@ -39,7 +39,7 @@ String _getSteamCode(Code code, [bool isNext = false]) { } String getNextTotp(Code code) { - if (code.type == Type.steam) { + if (code.type == Type.steam || code.issuer.toLowerCase() == 'steam') { return _getSteamCode(code, true); } return otp.OTP.generateTOTPCodeString( diff --git a/auth/linux/CMakeLists.txt b/auth/linux/CMakeLists.txt index ca3bd0a60e..2c0f9c6882 100644 --- a/auth/linux/CMakeLists.txt +++ b/auth/linux/CMakeLists.txt @@ -34,7 +34,7 @@ if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) "Debug" "Profile" "Release") endif() -# Compilation ui.settings that should be applied to most targets. +# Compilation settings that should be applied to most targets. # # Be cautious about adding new options here, as plugins use this function by # default. In most cases, you should add new options to specific targets instead @@ -66,8 +66,8 @@ add_executable(${BINARY_NAME} "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" ) -# Apply the standard set of build ui.settings. This can be removed for applications -# that need different build ui.settings. +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. apply_standard_settings(${BINARY_NAME}) # Add dependency libraries. Add any application-specific dependencies here. @@ -86,6 +86,7 @@ set_target_properties(${BINARY_NAME} RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" ) + # Generated plugin build rules, which manage building the plugins and adding # them to the application. include(flutter/generated_plugins.cmake) @@ -122,6 +123,12 @@ foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) COMPONENT Runtime) endforeach(bundled_library) +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + # Fully re-copy the assets directory on each build to avoid having stale files # from a previous install. set(FLUTTER_ASSET_DIR_NAME "flutter_assets") diff --git a/auth/linux/my_application.cc b/auth/linux/my_application.cc index 8a553fec35..c2a17c32cf 100644 --- a/auth/linux/my_application.cc +++ b/auth/linux/my_application.cc @@ -63,7 +63,7 @@ static void my_application_activate(GApplication *application) } gtk_window_set_default_size(window, 1280, 720); - gtk_widget_realize(GTK_WIDGET(window)); + gtk_widget_show(GTK_WIDGET(window)); g_autoptr(FlDartProject) project = fl_dart_project_new(); fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); @@ -73,6 +73,7 @@ static void my_application_activate(GApplication *application) gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + gtk_widget_hide(GTK_WIDGET(window)); gtk_widget_grab_focus(GTK_WIDGET(view)); } @@ -98,6 +99,26 @@ static gboolean my_application_local_command_line(GApplication *application, gch return FALSE; } +// Implements GApplication::startup. +static void my_application_startup(GApplication *application) +{ + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication *application) +{ + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + // Implements GObject::dispose. static void my_application_dispose(GObject *object) { @@ -110,6 +131,8 @@ static void my_application_class_init(MyApplicationClass *klass) { G_APPLICATION_CLASS(klass)->activate = my_application_activate; G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; G_OBJECT_CLASS(klass)->dispose = my_application_dispose; } diff --git a/auth/macos/Runner/Info.plist b/auth/macos/Runner/Info.plist index 418d695c9f..b48ea505b9 100644 --- a/auth/macos/Runner/Info.plist +++ b/auth/macos/Runner/Info.plist @@ -38,6 +38,7 @@ CFBundleURLSchemes otpauth + enteauth diff --git a/auth/pubspec.lock b/auth/pubspec.lock index b3a643b0be..d49014e282 100644 --- a/auth/pubspec.lock +++ b/auth/pubspec.lock @@ -293,9 +293,9 @@ packages: dependency: "direct main" description: path: "packages/desktop_webview_window" - ref: fix-webkit-version - resolved-ref: fe2223e4edfecdbb3a97bb9e3ced73db4ae9d979 - url: "https://github.com/ente-io/flutter-desktopwebview-fork" + ref: main + resolved-ref: "726d8281a244d56ab36e843f0427c48de6d9cc56" + url: "https://github.com/MixinNetwork/flutter-plugins" source: git version: "0.2.4" device_info_plus: @@ -602,11 +602,11 @@ packages: dependency: "direct overridden" description: path: flutter_secure_storage_linux - ref: patch-1 - resolved-ref: da8ab43bc51c8c3249a261c33b27aa6f018f819b - url: "https://github.com/prateekmedia/flutter_secure_storage.git" + ref: develop + resolved-ref: cb30953edc029dc4059b72700270b4cd3a3afade + url: "https://github.com/mogol/flutter_secure_storage.git" source: git - version: "1.2.0" + version: "1.2.1" flutter_secure_storage_macos: dependency: transitive description: @@ -813,10 +813,10 @@ packages: dependency: "direct main" description: name: intl - sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf url: "https://pub.dev" source: hosted - version: "0.18.1" + version: "0.19.0" io: dependency: transitive description: @@ -853,26 +853,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "78eb209deea09858f5269f5a5b02be4049535f568c07b275096836f01ea323fa" + sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" url: "https://pub.dev" source: hosted - version: "10.0.0" + version: "10.0.4" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: b46c5e37c19120a8a01918cfaf293547f47269f7cb4b0058f21531c2465d6ef0 + sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.3" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: a597f72a664dbd293f3bfc51f9ba69816f84dcd403cdac7066cb3f6003f3ab47 + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.1" lints: dependency: "direct dev" description: @@ -957,10 +957,10 @@ packages: dependency: transitive description: name: meta - sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04 + sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" url: "https://pub.dev" source: hosted - version: "1.11.0" + version: "1.12.0" mime: dependency: transitive description: @@ -1523,10 +1523,10 @@ packages: dependency: transitive description: name: test_api - sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" + sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" url: "https://pub.dev" source: hosted - version: "0.6.1" + version: "0.7.0" timezone: dependency: transitive description: @@ -1683,10 +1683,10 @@ packages: dependency: transitive description: name: vm_service - sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957 + sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" url: "https://pub.dev" source: hosted - version: "13.0.0" + version: "14.2.1" watcher: dependency: transitive description: @@ -1731,10 +1731,10 @@ packages: dependency: "direct main" description: name: window_manager - sha256: b3c895bdf936c77b83c5254bec2e6b3f066710c1f89c38b20b8acc382b525494 + sha256: "8699323b30da4cdbe2aa2e7c9de567a6abd8a97d9a5c850a3c86dcd0b34bbfbf" url: "https://pub.dev" source: hosted - version: "0.3.8" + version: "0.3.9" xdg_directories: dependency: transitive description: diff --git a/auth/pubspec.yaml b/auth/pubspec.yaml index 3a127cee31..6a36132ab8 100644 --- a/auth/pubspec.yaml +++ b/auth/pubspec.yaml @@ -1,6 +1,6 @@ name: ente_auth description: ente two-factor authenticator -version: 3.0.4+304 +version: 3.0.12+312 publish_to: none environment: @@ -20,8 +20,8 @@ dependencies: convert: ^3.1.1 desktop_webview_window: git: - url: https://github.com/ente-io/flutter-desktopwebview-fork - ref: fix-webkit-version + url: https://github.com/MixinNetwork/flutter-plugins + ref: main path: packages/desktop_webview_window device_info_plus: ^9.1.1 dio: ^5.4.0 @@ -64,7 +64,7 @@ dependencies: google_nav_bar: ^5.0.5 #supported gradient_borders: ^1.0.0 http: ^1.1.0 - intl: ^0.18.0 + intl: ^0.19.0 json_annotation: ^4.5.0 local_auth: ^2.2.0 local_auth_android: ^1.0.37 @@ -102,13 +102,13 @@ dependencies: url_launcher: ^6.1.5 uuid: ^4.2.2 win32: ^5.1.1 - window_manager: ^0.3.8 + window_manager: ^0.3.9 dependency_overrides: flutter_secure_storage_linux: git: - url: https://github.com/prateekmedia/flutter_secure_storage.git - ref: patch-1 + url: https://github.com/mogol/flutter_secure_storage.git + ref: develop path: flutter_secure_storage_linux dev_dependencies: build_runner: ^2.1.11 diff --git a/auth/windows/runner/resources/app_icon.ico b/auth/windows/runner/resources/app_icon.ico index 38bb22bcf8..40b9b4837b 100644 Binary files a/auth/windows/runner/resources/app_icon.ico and b/auth/windows/runner/resources/app_icon.ico differ diff --git a/cli/README.md b/cli/README.md index 40858da0f8..8edaf35509 100644 --- a/cli/README.md +++ b/cli/README.md @@ -107,11 +107,7 @@ docker-compose up -d `exec` into the container ```shell -docker-compose exec ente /bin/sh +docker-compose exec ente-cli /bin/sh -c "./ente-cli version" +docker-compose exec ente-cli /bin/sh -c "./ente-cli account add" ``` -#### Directly executing commands - -```shell -docker run -it --rm ente:latest ls -``` diff --git a/cli/docker-compose.yml b/cli/docker-compose.yml index b3c8ad0cea..d508155115 100644 --- a/cli/docker-compose.yml +++ b/cli/docker-compose.yml @@ -4,8 +4,11 @@ services: image: ente-cli:latest command: /bin/sh volumes: - # Replace /Volumes/Data/ with a folder path on your system, typically $HOME/.ente-cli/ - - ~/.ente-cli/:/cli-data:rw -# - ~/Downloads/export-data:/data:rw + # This is mandatory to mount the local directory to the container at /cli-data + # CLI will use this directory to store the data required for syncing export + - /path/to/local/directory/cli/:/cli-data:rw + # You can add additional volumes to mount the export directory to the container + # While adding account for export, you can use /data as the export directory. + - /path/to/local/directory/export:/data:rw stdin_open: true tty: true diff --git a/cli/internal/api/client.go b/cli/internal/api/client.go index b2f1645899..9309360b81 100644 --- a/cli/internal/api/client.go +++ b/cli/internal/api/client.go @@ -71,12 +71,15 @@ func NewClient(p Params) *Client { restClient: enteAPI, downloadClient: resty.New(). SetRetryCount(3). - SetRetryWaitTime(5 * time.Second). - SetRetryMaxWaitTime(10 * time.Second). + SetRetryWaitTime(10 * time.Second). + SetRetryMaxWaitTime(20 * time.Second). AddRetryCondition(func(r *resty.Response, err error) bool { - shouldRetry := r.StatusCode() == 429 || r.StatusCode() > 500 + shouldRetry := r.StatusCode() == 429 || r.StatusCode() >= 500 if shouldRetry { - log.Printf("retrying download due to %d code", r.StatusCode()) + amxRequestID := r.Header().Get("X-Amz-Request-Id") + cfRayID := r.Header().Get("CF-Ray") + wasabiRefID := r.Header().Get("X-Wasabi-Cm-Reference-Id") + log.Printf("Retry scheduled. error statusCode: %d, X-Amz-Request-Id: %s, CF-Ray: %s, X-Wasabi-Cm-Reference-Id: %s", r.StatusCode(), amxRequestID, cfRayID, wasabiRefID) } return shouldRetry }), diff --git a/cli/main.go b/cli/main.go index 05ea3a6e27..88de93025a 100644 --- a/cli/main.go +++ b/cli/main.go @@ -15,7 +15,7 @@ import ( "strings" ) -var AppVersion = "0.1.14" +var AppVersion = "0.1.15" func main() { cliDBPath, err := GetCLIConfigPath() @@ -23,7 +23,7 @@ func main() { cliDBPath = constants.CliDataPath _, err := internal.ValidateDirForWrite(cliDBPath) if err != nil { - log.Fatalf("Please mount a volume to %s to persist cli data\n%v\n", cliDBPath, err) + log.Fatalf("Please mount a volume to %s\n%v\n", cliDBPath, err) } } if err != nil { diff --git a/desktop/.github/workflows/desktop-release.yml b/desktop/.github/workflows/desktop-release.yml index 67385b1932..40fbbd3f85 100644 --- a/desktop/.github/workflows/desktop-release.yml +++ b/desktop/.github/workflows/desktop-release.yml @@ -1,6 +1,6 @@ name: "Release" -# Build the ente-io/ente's desktop/rc branch and create/update a draft release. +# Build the desktop app with code from ente-io/ente and create/update a release. # # For more details, see `docs/release.md` in ente-io/ente. diff --git a/desktop/CHANGELOG.md b/desktop/CHANGELOG.md index b6dccf60e5..92220f8928 100644 --- a/desktop/CHANGELOG.md +++ b/desktop/CHANGELOG.md @@ -2,7 +2,12 @@ ## v1.7.1 (Unreleased) +- Support for passkeys as a second factor authentication mechanism. - Remember the window size across app restarts. +- Revert changes to the Linux icon. +- Fix an issue causing deleted items in watched folders to not move to + uncategorized. +- Fix duplicate file uploads when initializing a folder watch (sometimes). ## v1.7.0 diff --git a/desktop/build/icons/512-512.png b/desktop/build/icons/512-512.png deleted file mode 100644 index 9d4d8ced53..0000000000 Binary files a/desktop/build/icons/512-512.png and /dev/null differ diff --git a/desktop/build/window-icon.png b/desktop/build/window-icon.png new file mode 100644 index 0000000000..5b0458033d Binary files /dev/null and b/desktop/build/window-icon.png differ diff --git a/desktop/electron-builder.yml b/desktop/electron-builder.yml index c2c000ce9f..6e3df57656 100644 --- a/desktop/electron-builder.yml +++ b/desktop/electron-builder.yml @@ -6,6 +6,9 @@ files: extraFiles: - from: build to: resources +protocols: + - name: Ente + schemes: ["ente"] win: target: - target: nsis @@ -23,6 +26,7 @@ linux: - target: pacman arch: [x64, arm64] category: Photography + icon: ./build/icon.icns mac: target: target: default diff --git a/desktop/src/main.ts b/desktop/src/main.ts index 5dd8254eb5..50f69759da 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -61,6 +61,103 @@ export const allowWindowClose = (): void => { shouldAllowWindowClose = true; }; +/** + * The app's entry point. + * + * We call this at the end of this file. + */ +const main = () => { + const gotTheLock = app.requestSingleInstanceLock(); + if (!gotTheLock) { + app.quit(); + return; + } + + let mainWindow: BrowserWindow | undefined; + + initLogging(); + logStartupBanner(); + registerForEnteLinks(); + // The order of the next two calls is important + setupRendererServer(); + registerPrivilegedSchemes(); + migrateLegacyWatchStoreIfNeeded(); + + /** + * Handle an open URL request, but ensuring that we have a mainWindow. + */ + const handleOpenURLEnsuringWindow = (url: string) => { + log.info(`Attempting to handle request to open URL: ${url}`); + if (mainWindow) handleEnteLinks(mainWindow, url); + else setTimeout(() => handleOpenURLEnsuringWindow(url), 1000); + }; + + app.on("second-instance", (_, argv: string[]) => { + // Someone tried to run a second instance, we should focus our window. + if (mainWindow) { + mainWindow.show(); + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.focus(); + } + // On Windows and Linux, this is how we get deeplinks. + // See: registerForEnteLinks + const url = argv.pop(); + if (url) handleOpenURLEnsuringWindow(url); + }); + + // Emitted once, when Electron has finished initializing. + // + // Note that some Electron APIs can only be used after this event occurs. + void app.whenReady().then(() => { + void (async () => { + // Create window and prepare for the renderer. + mainWindow = createMainWindow(); + + // Setup IPC and streams. + const watcher = createWatcher(mainWindow); + attachIPCHandlers(); + attachFSWatchIPCHandlers(watcher); + attachLogoutIPCHandler(watcher); + registerStreamProtocol(); + + // Configure the renderer's environment. + const webContents = mainWindow.webContents; + setDownloadPath(webContents); + allowExternalLinks(webContents); + allowAllCORSOrigins(webContents); + + // Start loading the renderer. + void mainWindow.loadURL(rendererURL); + + // Continue on with the rest of the startup sequence. + Menu.setApplicationMenu(await createApplicationMenu(mainWindow)); + setupTrayItem(mainWindow); + setupAutoUpdater(mainWindow); + + try { + await deleteLegacyDiskCacheDirIfExists(); + await deleteLegacyKeysStoreIfExists(); + } catch (e) { + // Log but otherwise ignore errors during non-critical startup + // actions. + log.error("Ignoring startup error", e); + } + })(); + }); + + // This is a macOS only event. Show our window when the user activates the + // app, e.g. by clicking on its dock icon. + app.on("activate", () => mainWindow?.show()); + + app.on("before-quit", () => { + if (mainWindow) saveWindowBounds(mainWindow); + allowWindowClose(); + }); + + // On macOS, this is how we get deeplinks. See: registerForEnteLinks + app.on("open-url", (_, url) => handleOpenURLEnsuringWindow(url)); +}; + /** * Log a standard startup banner. * @@ -137,12 +234,41 @@ const registerPrivilegedSchemes = () => { ]); }; +/** + * Register a handler for deeplinks, for the "ente://" protocol. + * + * See: [Note: Passkey verification in the desktop app]. + * + * Implementation notes: + * - https://www.electronjs.org/docs/latest/tutorial/launch-app-from-url-in-another-app + * - This works only when the app is packaged. + * - On Windows and Linux, we get the deeplink in the "second-instance" event. + * - On macOS, we get the deeplink in the "open-url" event. + */ +const registerForEnteLinks = () => app.setAsDefaultProtocolClient("ente"); + +/** Sibling of {@link registerForEnteLinks}. */ +const handleEnteLinks = (mainWindow: BrowserWindow, url: string) => { + // [Note: Using deeplinks to navigate in desktop app] + // + // Both + // + // - our deeplink protocol, and + // - the protocol we're using to serve/ our bundled web app + // + // use the same scheme ("ente://"), so the URL can directly be forwarded. + mainWindow.webContents.send("openURL", url); +}; + /** * Create an return the {@link BrowserWindow} that will form our app's UI. * * This window will show the HTML served from {@link rendererURL}. */ const createMainWindow = () => { + const icon = nativeImage.createFromPath( + path.join(isDev ? "build" : process.resourcesPath, "window-icon.png"), + ); const bounds = windowBounds(); // Create the main window. This'll show our web content. @@ -151,12 +277,11 @@ const createMainWindow = () => { preload: path.join(__dirname, "preload.js"), sandbox: true, }, + icon, // Set the window's position and size (if we have one saved). ...(bounds ?? {}), // Enforce a minimum size ...minimumWindowSize(), - // (Maybe) fix the dock icon on Linux. - ...windowIconOptions(), // The color to show in the window until the web content gets loaded. // See: https://www.electronjs.org/docs/latest/api/browser-window#setting-the-backgroundcolor-property backgroundColor: "black", @@ -220,12 +345,27 @@ const createMainWindow = () => { }; /** - * The position and size of the window the last time it was closed. + * The position and size to use when showing the main window. * - * The return value of `undefined` is taken to mean that the app's main window - * should be maximized. + * The return value is `undefined` if the app's window was maximized the last + * time around, and so if we should restore it to the maximized state. + * + * Otherwise it returns the position and size of the window the last time the + * app quit. + * + * If there is no such saved value (or if it is the first time the user is + * running the app), return a default size. */ -const windowBounds = () => userPreferences.get("windowBounds"); +const windowBounds = () => { + if (userPreferences.get("isWindowMaximized")) return undefined; + + const bounds = userPreferences.get("windowBounds"); + if (bounds) return bounds; + + // Default size. Picked arbitrarily as something that should look good on + // first launch. We don't provide a position to let Electron center the app. + return { width: 1170, height: 710 }; +}; /** * If for some reason {@link windowBounds} is outside the screen's bounds (e.g. @@ -233,10 +373,11 @@ const windowBounds = () => userPreferences.get("windowBounds"); * bounds might not be appropriate. * * Luckily, if we try to set an x/y position that is outside the screen's - * bounds, then Electron automatically clamps them to the screen's available - * space, and we do not need to tackle it specifically. + * bounds, then Electron automatically clamps x + width and y + height to lie + * within the screen's available space, and we do not need to tackle such out of + * bounds cases specifically. * - * However, there is no minimum window size the Electron enforces by default. As + * However there is no minimum window size the Electron enforces by default. As * a safety valve, provide an (arbitrary) minimum size so that the user can * resize it back to sanity if something I cannot currently anticipate happens. */ @@ -247,55 +388,13 @@ const minimumWindowSize = () => ({ minWidth: 200, minHeight: 200 }); * details. */ const saveWindowBounds = (window: BrowserWindow) => { - if (window.isMaximized()) userPreferences.delete("windowBounds"); - else userPreferences.set("windowBounds", window.getBounds()); -}; - -/** - * On Linux the app does not show a dock icon by default, attempt to fix this by - * returning the path to an icon as the "icon" property that can be passed to - * the BrowserWindow during creation. - */ -const windowIconOptions = () => { - if (process.platform != "linux") return {}; - - // There are two, possibly three, different issues with icons on Linux. - // - // Firstly, the AppImage itself doesn't show an icon. There does not seem to - // be a reasonable workaround either currently. See: - // https://github.com/AppImage/AppImageKit/issues/346 - // - // Secondly, and this is the problem we're trying to fix here, when the app - // is started it does not show a dock icon (Ubuntu 22) or shows the generic - // gear icon (Ubuntu 24). The issue possibly exists on other distributions - // too. - // - // Electron provides a `BrowserWindow.setIcon` function which should solve - // our issue, we could call it selectively on Linux. There is also an - // apparently undocumented "icon" option that can be passed when creating a - // new BrowserWindow, and that is what most of the other code I saw on - // GitHub seems to be doing. - // - // However, try what I may, I can't get either of these to work. Which leads - // me to believe there is a third issue: I can't get it to work because I'm - // testing on an Ubuntu 24 VM, where this might just not be working: - // https://askubuntu.com/questions/1511534/ubuntu-24-04-skype-logo-on-the-dock-not-showing-skype-logo - // - // 24 isn't likely the year of the Linux desktop either. - // - // For now, I'm adding a very specific incantation taken from - // https://github.com/arduino/arduino-ide/blob/main/arduino-ide-extension/src/electron-main/fix-app-image-icon.ts - // - // Possibly all this specific naming of the file etc is superstition, and - // just any name would do as long as the path is correct, but let me try it - // this way and see if this gets the icon to appear on Ubuntu 22 etc. - - const icon = path.join( - isDev ? "build" : process.resourcesPath, - "icons/512x512.png", - ); - - return { icon }; + if (window.isMaximized()) { + userPreferences.set("isWindowMaximized", true); + userPreferences.delete("windowBounds"); + } else { + userPreferences.delete("isWindowMaximized"); + userPreferences.set("windowBounds", window.getBounds()); + } }; /** @@ -464,79 +563,5 @@ const deleteLegacyKeysStoreIfExists = async () => { } }; -const main = () => { - const gotTheLock = app.requestSingleInstanceLock(); - if (!gotTheLock) { - app.quit(); - return; - } - - let mainWindow: BrowserWindow | undefined; - - initLogging(); - logStartupBanner(); - // The order of the next two calls is important - setupRendererServer(); - registerPrivilegedSchemes(); - migrateLegacyWatchStoreIfNeeded(); - - app.on("second-instance", () => { - // Someone tried to run a second instance, we should focus our window. - if (mainWindow) { - mainWindow.show(); - if (mainWindow.isMinimized()) mainWindow.restore(); - mainWindow.focus(); - } - }); - - // Emitted once, when Electron has finished initializing. - // - // Note that some Electron APIs can only be used after this event occurs. - void app.whenReady().then(() => { - void (async () => { - // Create window and prepare for the renderer. - mainWindow = createMainWindow(); - - // Setup IPC and streams. - const watcher = createWatcher(mainWindow); - attachIPCHandlers(); - attachFSWatchIPCHandlers(watcher); - attachLogoutIPCHandler(watcher); - registerStreamProtocol(); - - // Configure the renderer's environment. - const webContents = mainWindow.webContents; - setDownloadPath(webContents); - allowExternalLinks(webContents); - allowAllCORSOrigins(webContents); - - // Start loading the renderer. - void mainWindow.loadURL(rendererURL); - - // Continue on with the rest of the startup sequence. - Menu.setApplicationMenu(await createApplicationMenu(mainWindow)); - setupTrayItem(mainWindow); - setupAutoUpdater(mainWindow); - - try { - await deleteLegacyDiskCacheDirIfExists(); - await deleteLegacyKeysStoreIfExists(); - } catch (e) { - // Log but otherwise ignore errors during non-critical startup - // actions. - log.error("Ignoring startup error", e); - } - })(); - }); - - // This is a macOS only event. Show our window when the user activates the - // app, e.g. by clicking on its dock icon. - app.on("activate", () => mainWindow?.show()); - - app.on("before-quit", () => { - if (mainWindow) saveWindowBounds(mainWindow); - allowWindowClose(); - }); -}; - +// Go for it. main(); diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 6e7df7cdea..55f5f8530e 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -46,7 +46,12 @@ import { computeCLIPTextEmbeddingIfAvailable, } from "./services/ml-clip"; import { computeFaceEmbeddings, detectFaces } from "./services/ml-face"; -import { encryptionKey, saveEncryptionKey } from "./services/store"; +import { + encryptionKey, + lastShownChangelogVersion, + saveEncryptionKey, + setLastShownChangelogVersion, +} from "./services/store"; import { clearPendingUploads, listZipItems, @@ -101,11 +106,19 @@ export const attachIPCHandlers = () => { ipcMain.handle("selectDirectory", () => selectDirectory()); + ipcMain.handle("encryptionKey", () => encryptionKey()); + ipcMain.handle("saveEncryptionKey", (_, encryptionKey: string) => saveEncryptionKey(encryptionKey), ); - ipcMain.handle("encryptionKey", () => encryptionKey()); + ipcMain.handle("lastShownChangelogVersion", () => + lastShownChangelogVersion(), + ); + + ipcMain.handle("setLastShownChangelogVersion", (_, version: number) => + setLastShownChangelogVersion(version), + ); // - App update diff --git a/desktop/src/main/services/store.ts b/desktop/src/main/services/store.ts index 253c2cbf0c..4663c2525f 100644 --- a/desktop/src/main/services/store.ts +++ b/desktop/src/main/services/store.ts @@ -1,12 +1,16 @@ import { safeStorage } from "electron/main"; import { safeStorageStore } from "../stores/safe-storage"; import { uploadStatusStore } from "../stores/upload-status"; +import { userPreferences } from "../stores/user-preferences"; import { watchStore } from "../stores/watch"; /** * Clear all stores except user preferences. * - * This is useful to reset state when the user logs out. + * This function is useful to reset state when the user logs out. User + * preferences are preserved since they contain things tied to the person using + * the app or other machine specific state not tied to the account they were + * using inside the app. */ export const clearStores = () => { safeStorageStore.clear(); @@ -32,3 +36,9 @@ export const encryptionKey = (): string | undefined => { const keyBuffer = Buffer.from(b64EncryptedKey, "base64"); return safeStorage.decryptString(keyBuffer); }; + +export const lastShownChangelogVersion = (): number | undefined => + userPreferences.get("lastShownChangelogVersion"); + +export const setLastShownChangelogVersion = (version: number) => + userPreferences.set("lastShownChangelogVersion", version); diff --git a/desktop/src/main/services/watch.ts b/desktop/src/main/services/watch.ts index e9629ff703..a65fade8f5 100644 --- a/desktop/src/main/services/watch.ts +++ b/desktop/src/main/services/watch.ts @@ -23,6 +23,12 @@ export const createWatcher = (mainWindow: BrowserWindow) => { const folderPaths = folderWatches().map((watch) => watch.folderPath); const watcher = chokidar.watch(folderPaths, { + // Don't emit "add" events for matching paths when instantiating the + // watch (we do a full disk scan on launch on our own, and also getting + // the same events from the watcher causes duplicates). + ignoreInitial: true, + // Ask the watcher to wait for a the file size to stabilize before + // telling us about a new file. By default, it waits for 2 seconds. awaitWriteFinish: true, }); diff --git a/desktop/src/main/stores/user-preferences.ts b/desktop/src/main/stores/user-preferences.ts index 457556ce59..400e8f6833 100644 --- a/desktop/src/main/stores/user-preferences.ts +++ b/desktop/src/main/stores/user-preferences.ts @@ -9,15 +9,19 @@ interface UserPreferences { hideDockIcon?: boolean; skipAppVersion?: string; muteUpdateNotificationVersion?: string; + /** + * The changelog version for which we last showed the "What's new" screen. + * + * See: [Note: Conditions for showing "What's new"] + */ + lastShownChangelogVersion?: number; /** * The last position and size of our app's window. * * This value is saved when the app is about to quit, and is used to restore - * the window to the previous state when it restarts. - * - * If the user maximizes the window then this value is cleared and instead - * we just re-maximize the window on restart. This is also the behaviour if - * no previously saved `windowRect` is found. + * the window to the previous state when it restarts. It is only saved if + * the app is not maximized (when the app was maximized when it was being + * quit then {@link isWindowMaximized} will be set instead). */ windowBounds?: { x: number; @@ -25,12 +29,17 @@ interface UserPreferences { width: number; height: number; }; + /** + * `true` if the app's main window is maximized the last time it was closed. + */ + isWindowMaximized?: boolean; } const userPreferencesSchema: Schema = { hideDockIcon: { type: "boolean" }, skipAppVersion: { type: "string" }, muteUpdateNotificationVersion: { type: "string" }, + lastShownChangelogVersion: { type: "number" }, windowBounds: { properties: { x: { type: "number" }, @@ -39,6 +48,7 @@ const userPreferencesSchema: Schema = { height: { type: "number" }, }, }, + isWindowMaximized: { type: "boolean" }, }; export const userPreferences = new Store({ diff --git a/desktop/src/preload.ts b/desktop/src/preload.ts index c6df891dc5..50fb8b15c7 100644 --- a/desktop/src/preload.ts +++ b/desktop/src/preload.ts @@ -72,15 +72,26 @@ const encryptionKey = () => ipcRenderer.invoke("encryptionKey"); const saveEncryptionKey = (encryptionKey: string) => ipcRenderer.invoke("saveEncryptionKey", encryptionKey); -const onMainWindowFocus = (cb?: () => void) => { +const lastShownChangelogVersion = () => + ipcRenderer.invoke("lastShownChangelogVersion"); + +const setLastShownChangelogVersion = (version: number) => + ipcRenderer.invoke("setLastShownChangelogVersion", version); + +const onMainWindowFocus = (cb: (() => void) | undefined) => { ipcRenderer.removeAllListeners("mainWindowFocus"); if (cb) ipcRenderer.on("mainWindowFocus", cb); }; +const onOpenURL = (cb: ((url: string) => void) | undefined) => { + ipcRenderer.removeAllListeners("openURL"); + if (cb) ipcRenderer.on("openURL", (_, url: string) => cb(url)); +}; + // - App update const onAppUpdateAvailable = ( - cb?: ((update: AppUpdate) => void) | undefined, + cb: ((update: AppUpdate) => void) | undefined, ) => { ipcRenderer.removeAllListeners("appUpdateAvailable"); if (cb) { @@ -306,7 +317,10 @@ contextBridge.exposeInMainWorld("electron", { logout, encryptionKey, saveEncryptionKey, + lastShownChangelogVersion, + setLastShownChangelogVersion, onMainWindowFocus, + onOpenURL, // - App update diff --git a/docs/README.md b/docs/README.md index 6d3f926361..b53178489a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -44,7 +44,8 @@ yarn dev For an editor, VSCode is a good choice. Also install the Prettier extension for VSCode, and set VSCode to format on save. This way the editor will automatically format and wrap the text using the project's standard, so you can just focus on -the content. +the content. You can also format without VSCode by using the `yarn pretty` +command. ## Have fun! diff --git a/docs/docs/.vitepress/sidebar.ts b/docs/docs/.vitepress/sidebar.ts index ef7ee47c4a..478f070c09 100644 --- a/docs/docs/.vitepress/sidebar.ts +++ b/docs/docs/.vitepress/sidebar.ts @@ -44,6 +44,10 @@ export const sidebar = [ link: "/photos/features/location-tags", }, { text: "Map", link: "/photos/features/map" }, + { + text: "Passkeys", + link: "/photos/features/passkeys", + }, { text: "Public link", link: "/photos/features/public-link", @@ -167,6 +171,10 @@ export const sidebar = [ text: "From Steam", link: "/auth/migration-guides/steam/", }, + { + text: "From others", + link: "/auth/migration-guides/import", + }, { text: "Exporting your data", link: "/auth/migration-guides/export", diff --git a/docs/docs/auth/migration-guides/import.md b/docs/docs/auth/migration-guides/import.md new file mode 100644 index 0000000000..afb19c9f19 --- /dev/null +++ b/docs/docs/auth/migration-guides/import.md @@ -0,0 +1,39 @@ +--- +title: Migrating from other providers +description: + Guide for importing your existing 2FA tokens into Ente Auth from other + providers +--- + +# Migrating from other providers + +--- + +Ente Auth natively supports imports from many 2FA providers. In addition to the +providers specifically listed in the documentation, the supported providers are: + +- 2FAS Authenticator +- Aegis Authenticator +- Bitwarden +- Google Authenticator +- Ravio OTP +- LastPass + +Details as to how codes may be imported from these providers may be found within +the app. + +> [!NOTE] +> +> Please note that this list may be out of sync, please see the app for the +> latest set of supported providers. + +Ente Auth also supports imports from Auth's own encrypted exports and plain text +files. Plain text files must be in the following format: + +`otpauth://totp/provider.com:you@email.com?secret=YOUR_SECRET` + +The codes can be seperated by a comma or a new line. + +So if your provider is not specifically listed, you might be still able to +import from them by first converting the data from your old provider into these +plaintext files and then importing those into Ente. diff --git a/docs/docs/auth/migration-guides/index.md b/docs/docs/auth/migration-guides/index.md index 3fb638ca1e..efc8e9fb03 100644 --- a/docs/docs/auth/migration-guides/index.md +++ b/docs/docs/auth/migration-guides/index.md @@ -8,4 +8,5 @@ description: - [Migrating from Authy](authy/) - [Importing codes from Steam](steam/) +- [Migrating from other apps](import) - [Exporting your data out of Ente Auth](export) diff --git a/docs/docs/photos/faq/security-and-privacy.md b/docs/docs/photos/faq/security-and-privacy.md index 5aba33e8f1..9dadf8bdf1 100644 --- a/docs/docs/photos/faq/security-and-privacy.md +++ b/docs/docs/photos/faq/security-and-privacy.md @@ -87,3 +87,16 @@ Yes, Ente Photos has undergone a thorough security audit conducted by Cure53, in collaboration with Symbolic Software. Cure53 is a prominent German cybersecurity firm, while Symbolic Software specializes in applied cryptography. Please find the full report here: https://ente.io/blog/cryptography-audit/ + +## How can I delete my account? + +You can delete your account at any time by using the "Delete account" option in +the settings. For security reasons, we request you to delete your account on +your own instead of contacting support to ask them to delete your account. + +Note that both Ente photos and Ente auth data will be deleted when you delete +your account (irrespective of which app you delete it from) since both photos +and auth use the same underlying account. + +To know details of how your data is deleted, including when you delete your +account, please see https://ente.io/blog/how-ente-deletes-data/. diff --git a/docs/docs/photos/features/passkeys.md b/docs/docs/photos/features/passkeys.md new file mode 100644 index 0000000000..f485c4f9b6 --- /dev/null +++ b/docs/docs/photos/features/passkeys.md @@ -0,0 +1,62 @@ +--- +title: Passkeys +description: Using passkeys as a second factor for your Ente account +--- + +# Passkeys + +> [!CAUTION] +> +> This is preview documentation for an upcoming feature. This feature has not +> yet been released yet, so the steps below will not work currently. + +Passkeys are a new authentication mechanism that uses strong cryptography built +into devices, like Windows Hello or Apple's Touch ID. **You can use passkeys as +a second factor to secure your Ente account.** + +> [!TIP] +> +> Passkeys are the colloquial term for a WebAuthn (Web Authentication) +> credentials. To know more technical details about how our passkey verification +> works, you can see this +> [technical note in our source code](https://github.com/ente-io/ente/blob/main/web/docs/webauthn-passkeys.md). + +## Passkeys and TOTP + +Ente already supports TOTP codes (in fact, we built an +[entire app](https://ente.io/auth/) to store them...). Passkeys serve as an +alternative 2FA (second factor) mechanism. + +If you add a passkey to your Ente account, it will be used instead of any +existing 2FA codes that you have configured (if any). + +## Enabling and disabling passkeys + +Passkeys get enabled if you add one (or more) passkeys to your account. +Conversely, passkeys get disabled if you remove all your existing passkeys. + +To add and remove passkeys, use the _Passkey_ option in the settings menu. This +will open up _accounts.ente.io_, where you can manage your passkeys. + +## Login with passkeys + +If passkeys are enabled, then _accounts.ente.io_ will automatically open when +you log into your Ente account on a new device. Here you can follow the +instructions given by the browser to verify your passkey. + +> These instructions different for each browser and device, but generally they +> will ask you to use the same mechanism that you used when you created the +> passkey to verify it (scanning a QR code, using your fingerprint, pressing the +> key on your Yubikey or other security key hardware etc). + +## Recovery + +If you are unable to login with your passkey (e.g. if you have misplaced the +hardware key that you used to store your passkey), then you can **recover your +account by using your Ente recovery key**. + +During login, press cancel on the browser dialog to verify your passkey, and +then select the "Recover two-factor" option in the error message that gets +shown. This will take you to a place where you can enter your Ente recovery key +and login into your account. Now you can go to the _Passkey_ page to delete the +lost passkey and/or add a new one. diff --git a/docs/docs/photos/troubleshooting/desktop-install/index.md b/docs/docs/photos/troubleshooting/desktop-install/index.md index 7410c7818e..d5084be503 100644 --- a/docs/docs/photos/troubleshooting/desktop-install/index.md +++ b/docs/docs/photos/troubleshooting/desktop-install/index.md @@ -9,6 +9,9 @@ The latest version of the Ente Photos desktop app can be downloaded from [ente.io/download](https://ente.io/download). If you're having trouble, please see if any of the following cases apply. +- [Windows](#windows) +- [Linux](#linux) + ## Windows If the app stops with an "A JavaScript error occurred in the main process - The @@ -22,7 +25,26 @@ This is what the error looks like: You can install the Microsoft VC++ redistributable runtime from here:
https://learn.microsoft.com/en-us/cpp/windows/latest-supported-vc-redist?view=msvc-170#latest-microsoft-visual-c-redistributable-version -## AppImages on ARM64 Linux +## Linux + +### AppImage desktop integration + +AppImages are not fully standalone, and they require additional steps to enable +full "desktop integration": + +- Showing the app icon, +- Surfacing the app in the list of installed apps, +- Handling redirection after passkey verification. + +All the ways of enabling AppImage desktop integration are mentioned in +[AppImage documentation](https://docs.appimage.org/user-guide/run-appimages.html#integrating-appimages-into-the-desktop). + +For example, you can download the +[appimaged](https://github.com/probonopd/go-appimage/releases) AppImage, run it, +and then download the Ente Photos AppImage into your `~/Downloads` folder. +_appimaged_ will then pick it up automatically. + +### AppImages on ARM64 If you're on an ARM64 machine running Linux, and the AppImages doesn't do anything when you run it, you will need to run the following command on your @@ -42,7 +64,7 @@ details, see the following upstream issues: - libz.so: cannot open shared object file with Ubuntu arm64 - [electron-userland/electron-builder/issues/7835](https://github.com/electron-userland/electron-builder/issues/7835) -## AppImage says it requires FUSE +### AppImage says it requires FUSE See [docs.appimage.org](https://docs.appimage.org/user-guide/troubleshooting/fuse.html#the-appimage-tells-me-it-needs-fuse-to-run). @@ -53,7 +75,7 @@ tl;dr; for example, on Ubuntu, sudo apt install libfuse2 ``` -## Linux SUID error +### Linux SUID error On some Linux distributions, if you run the AppImage from the CLI, it might fail with the following error: diff --git a/docs/docs/self-hosting/faq/sharing.md b/docs/docs/self-hosting/faq/sharing.md index 4e3652ff7f..2eb5578448 100644 --- a/docs/docs/self-hosting/faq/sharing.md +++ b/docs/docs/self-hosting/faq/sharing.md @@ -57,3 +57,48 @@ apps: (For more details, see [local.yaml](https://github.com/ente-io/ente/blob/main/server/configurations/local.yaml) in the server's source code). + +## Dockerfile example + +Here is an example of a Dockerfile by @Dylanger on our community Discord. This +runs a standalone self-hosted version of the public albums app in production +mode. + +```Dockerfile +FROM node:20-alpine as builder + +WORKDIR /app +COPY . . + +ARG NEXT_PUBLIC_ENTE_ENDPOINT=https://your.ente.example.org +ARG NEXT_PUBLIC_ENTE_ALBUMS_ENDPOINT=https://your.albums.example.org + +RUN yarn install && yarn build + +FROM node:20-alpine + +WORKDIR /app +COPY --from=builder /app/apps/photos/out . + +RUN npm install -g serve + +ENV PORT=3000 +EXPOSE ${PORT} + +CMD serve -s . -l tcp://0.0.0.0:${PORT} +``` + +Note that this only runs the public albums app, but the same principle can be +used to run both the normal Ente photos app and the public albums app. There is +a slightly more involved example showing how to do this also provided by in a +community contributed guide about +[configuring external S3](/self-hosting/guides/external-s3). + +You will also want to tell museum about your custom shared albums endpoint so +that it uses that instead of the default URL when creating share links. You can +configure that in museum's `config.yaml`: + +``` +apps: + public-albums: https://your.albums.example.org +``` diff --git a/docs/docs/self-hosting/guides/external-s3.md b/docs/docs/self-hosting/guides/external-s3.md index 87a48de277..c65a927075 100644 --- a/docs/docs/self-hosting/guides/external-s3.md +++ b/docs/docs/self-hosting/guides/external-s3.md @@ -213,6 +213,8 @@ ENTE_INTERNAL_HARDCODED-OTT_LOCAL-DOMAIN-VALUE=123456 # it can be changed later ENDPOINT=http://localhost:8080 ALBUMS_ENDPOINT=http://localhost:8082 +# This is used to generate sharable URLs +ENTE_APPS_PUBLIC-ALBUMS=http://localhost:8082 ``` ## 3. Run `docker-compose up` diff --git a/infra/staff/.env b/infra/staff/.env new file mode 100644 index 0000000000..e5d8beabbe --- /dev/null +++ b/infra/staff/.env @@ -0,0 +1,9 @@ +# This is a sample .env file. The env vars defined here can be used to configure +# the app. +# +# For local development, create a new file named `.env.local` and add the +# variables you need there instead of uncommenting them below. +# +# For documentation about the variables, see `src/vite-env.d.ts`. + +# VITE_ENTE_API_ORIGIN = http://localhost:8080 diff --git a/infra/staff/.eslintrc.cjs b/infra/staff/.eslintrc.cjs new file mode 100644 index 0000000000..d1bbd1afa5 --- /dev/null +++ b/infra/staff/.eslintrc.cjs @@ -0,0 +1,37 @@ +/* eslint-env node */ +module.exports = { + root: true, + extends: [ + "eslint:recommended", + "plugin:@typescript-eslint/strict-type-checked", + "plugin:@typescript-eslint/stylistic-type-checked", + "plugin:react/recommended", + "plugin:react-hooks/recommended", + "plugin:react/jsx-runtime", + ], + plugins: ["@typescript-eslint", "react-refresh"], + parserOptions: { project: true }, + parser: "@typescript-eslint/parser", + ignorePatterns: [".eslintrc.cjs", "vite.config.ts", "dist"], + settings: { react: { version: "18.2" } }, + rules: { + /* Allow numbers to be used in template literals */ + "@typescript-eslint/restrict-template-expressions": [ + "error", + { + allowNumber: true, + }, + ], + /* Allow void expressions as the entire body of an arrow function */ + "@typescript-eslint/no-confusing-void-expression": [ + "error", + { + ignoreArrowShorthand: true, + }, + ], + "react-refresh/only-export-components": [ + "warn", + { allowConstantExport: true }, + ], + }, +}; diff --git a/infra/staff/.gitignore b/infra/staff/.gitignore new file mode 100644 index 0000000000..2c6a2dd2e8 --- /dev/null +++ b/infra/staff/.gitignore @@ -0,0 +1,17 @@ +# Node +node_modules/ + +# macOS +.DS_Store + +# Editors +.vscode/ + +# Local env files +.env*.local + +# tsc +*.tsbuildinfo + +# Vite +dist diff --git a/infra/staff/.prettierrc.json b/infra/staff/.prettierrc.json new file mode 100644 index 0000000000..7cf8c86c77 --- /dev/null +++ b/infra/staff/.prettierrc.json @@ -0,0 +1,8 @@ +{ + "tabWidth": 4, + "proseWrap": "always", + "plugins": [ + "prettier-plugin-organize-imports", + "prettier-plugin-packagejson" + ] +} diff --git a/infra/staff/README.md b/infra/staff/README.md new file mode 100644 index 0000000000..d6d567e1b3 --- /dev/null +++ b/infra/staff/README.md @@ -0,0 +1,19 @@ +## Staff dashboard + +Web app for staff members to help with support and other administration. + +### Development + +Use `yarn dev` to run a local dev server with hot reload. + +> [!TIP] +> +> See [web/docs/new.md](../../web/docs/new.md) for help in setting up your +> editor to do the formatting and linting. You can also run the formatter and +> linter manually using `yarn lint`, and `yarn lint-fix` to fix them. These +> commands automatically run on every PR. + +### Deployment + +The app gets redeployed whenever a PR is merged into main. See +[web/docs/deploy.md](../../web/docs/deploy.md) for more details. diff --git a/web/apps/staff/index.html b/infra/staff/index.html similarity index 100% rename from web/apps/staff/index.html rename to infra/staff/index.html diff --git a/infra/staff/package.json b/infra/staff/package.json new file mode 100644 index 0000000000..531544e985 --- /dev/null +++ b/infra/staff/package.json @@ -0,0 +1,35 @@ +{ + "name": "staff", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc && vite build", + "dev": "vite", + "lint": "yarn prettier --check --log-level warn . && yarn eslint . && yarn tsc", + "lint-fix": "yarn prettier --write --log-level warn . && yarn eslint --fix . && yarn tsc", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18", + "react-dom": "^18", + "zod": "^3" + }, + "devDependencies": { + "@types/react": "^18", + "@types/react-dom": "^18", + "@typescript-eslint/eslint-plugin": "^7", + "@typescript-eslint/parser": "^7", + "@vitejs/plugin-react": "^4.2", + "eslint": "^8", + "eslint-plugin-react": "^7.34", + "eslint-plugin-react-hooks": "^4.6", + "eslint-plugin-react-refresh": "^0.4.7", + "prettier": "^3", + "prettier-plugin-organize-imports": "^3.2", + "prettier-plugin-packagejson": "^2.5", + "typescript": "^5", + "vite": "^5.2" + }, + "packageManager": "yarn@1.22.21" +} diff --git a/infra/staff/src/App.tsx b/infra/staff/src/App.tsx new file mode 100644 index 0000000000..93a9cd5ae0 --- /dev/null +++ b/infra/staff/src/App.tsx @@ -0,0 +1,224 @@ +import React, { useEffect, useState } from "react"; +import { apiOrigin } from "./services/support"; +import S from "./utils/strings"; + +export const App: React.FC = () => { + const [token, setToken] = useState(""); + const [email, setEmail] = useState(""); + const [userData, setUserData] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + const storedToken = localStorage.getItem("token"); + if (storedToken) { + setToken(storedToken); + } + }, []); + + useEffect(() => { + if (token) { + localStorage.setItem("token", token); + } else { + localStorage.removeItem("token"); + } + }, [token]); + + const fetchData = async () => { + try { + const url = `${apiOrigin}/admin/user?email=${email}&token=${token}`; + const response = await fetch(url); + if (!response.ok) { + throw new Error("Network response was not ok"); + } + const userData = await response.json(); + console.log("API Response:", userData); + setUserData(userData); + setError(null); + } catch (error) { + console.error("Error fetching data:", error); + setError((error as Error).message); + } + }; + + const renderAttributes = (data: any) => { + if (!data) return null; + + let nullAttributes: string[] = []; + + const rows = Object.entries(data).map(([key, value]) => { + console.log("Processing key:", key, "value:", value); + + if ( + typeof value === "object" && + value !== null && + !Array.isArray(value) + ) { + return ( + + + + {key.toUpperCase()} + + + {renderAttributes(value)} + + ); + } else { + if (value === null) { + nullAttributes.push(key); + } + + let displayValue: React.ReactNode; + if (key === "expiryTime" && typeof value === "number") { + displayValue = new Date(value / 1000).toLocaleString(); + } else if ( + key === "creationTime" && + typeof value === "number" + ) { + displayValue = new Date(value / 1000).toLocaleString(); + } else if (key === "storage" && typeof value === "number") { + displayValue = `${(value / 1024 ** 3).toFixed(2)} GB`; + } else if (typeof value === "string") { + try { + const parsedValue = JSON.parse(value); + displayValue = parsedValue; + } catch (error) { + displayValue = value; + } + } else if (value === null) { + displayValue = "null"; + } else if (typeof value !== "undefined") { + displayValue = value.toString(); + } else { + displayValue = "undefined"; + } + + return ( + + + {key} + + + {displayValue} + + + ); + } + }); + + console.log("Attributes with null values:", nullAttributes); + + return rows; + }; + + return ( +
+

{S.hello}

+ +
+
+ +
+
+ +
+
+
+ +
+
+ {error &&

{`Error: ${error}`}

} + {userData && ( + + + {Object.keys(userData).map((category) => ( + + + + + {renderAttributes(userData[category])} + + ))} + +
+ {category.toUpperCase()} +
+ )} + +
+ ); +}; diff --git a/web/apps/staff/src/components/Container.tsx b/infra/staff/src/components/Container.tsx similarity index 100% rename from web/apps/staff/src/components/Container.tsx rename to infra/staff/src/components/Container.tsx diff --git a/web/apps/staff/src/main.tsx b/infra/staff/src/main.tsx similarity index 100% rename from web/apps/staff/src/main.tsx rename to infra/staff/src/main.tsx diff --git a/web/apps/staff/src/services/support-service.ts b/infra/staff/src/services/support.ts similarity index 87% rename from web/apps/staff/src/services/support-service.ts rename to infra/staff/src/services/support.ts index 3e22234e76..a4e0d93e84 100644 --- a/web/apps/staff/src/services/support-service.ts +++ b/infra/staff/src/services/support.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -const apiOrigin = import.meta.env.VITE_ENTE_ENDPOINT ?? "https://api.ente.io"; +export const apiOrigin = import.meta.env.VITE_ENTE_API_ORIGIN ?? "https://api.ente.io"; const UserDetails = z.object({}).passthrough(); diff --git a/web/apps/staff/src/styles/globals.css b/infra/staff/src/styles/globals.css similarity index 100% rename from web/apps/staff/src/styles/globals.css rename to infra/staff/src/styles/globals.css diff --git a/web/apps/staff/src/utils/strings.ts b/infra/staff/src/utils/strings.ts similarity index 100% rename from web/apps/staff/src/utils/strings.ts rename to infra/staff/src/utils/strings.ts diff --git a/web/apps/staff/src/vite-env.d.ts b/infra/staff/src/vite-env.d.ts similarity index 74% rename from web/apps/staff/src/vite-env.d.ts rename to infra/staff/src/vite-env.d.ts index b49bd06d07..185a374417 100644 --- a/web/apps/staff/src/vite-env.d.ts +++ b/infra/staff/src/vite-env.d.ts @@ -8,9 +8,9 @@ interface ImportMetaEnv { /** * Override the origin (scheme://host:port) of Ente's API to connect to. * - * This is useful when testing or connecting to alternative installations. + * Default is "https://api.ente.io". */ - readonly VITE_ENTE_ENDPOINT: string | undefined; + readonly VITE_ENTE_API_ORIGIN: string | undefined; } interface ImportMeta { diff --git a/infra/staff/tsconfig.json b/infra/staff/tsconfig.json new file mode 100644 index 0000000000..e4ff26ca2d --- /dev/null +++ b/infra/staff/tsconfig.json @@ -0,0 +1,48 @@ +{ + /* TSConfig file used for typechecking the files in src/. + * + * The base configuration was generated using `yarn create vite`. This was + * already almost the same as the `tsconfig-typecheck.json` we use + * elsewhere, with one or two differences. + * + * For more details about the flags vite cares about, see + * https://vitejs.dev/guide/features.html#typescript-compiler-options + */ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "esnext", + "skipLibCheck": true, + + /* Bundler mode. */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting. */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + + /* + * On top of the generated configuration, we've mostly added additional + * strictness checks. + */ + + /* Require the `type` modifier when importing types. */ + "verbatimModuleSyntax": true, + + /* Stricter than strict. */ + "noImplicitReturns": true, + /* e.g. makes array indexing returns undefined. */ + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/infra/staff/tsconfig.node.json b/infra/staff/tsconfig.node.json new file mode 100644 index 0000000000..71c4923013 --- /dev/null +++ b/infra/staff/tsconfig.node.json @@ -0,0 +1,15 @@ +{ + /* TSConfig file used for typechecking vite's config file itself. + * + * These are vite defaults, generated using `yarn create vite`. + */ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/web/apps/staff/vite.config.ts b/infra/staff/vite.config.ts similarity index 100% rename from web/apps/staff/vite.config.ts rename to infra/staff/vite.config.ts diff --git a/infra/staff/yarn.lock b/infra/staff/yarn.lock new file mode 100644 index 0000000000..8471e93856 --- /dev/null +++ b/infra/staff/yarn.lock @@ -0,0 +1,2654 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@ampproject/remapping@^2.2.0": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.3.0.tgz#ed441b6fa600072520ce18b43d2c8cc8caecc7f4" + integrity sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw== + dependencies: + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.24" + +"@babel/code-frame@^7.24.6": + version "7.24.6" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.24.6.tgz#ab88da19344445c3d8889af2216606d3329f3ef2" + integrity sha512-ZJhac6FkEd1yhG2AHOmfcXG4ceoLltoCVJjN5XsWN9BifBQr+cHJbWi0h68HZuSORq+3WtJ2z0hwF2NG1b5kcA== + dependencies: + "@babel/highlight" "^7.24.6" + picocolors "^1.0.0" + +"@babel/compat-data@^7.24.6": + version "7.24.6" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.24.6.tgz#b3600217688cabb26e25f8e467019e66d71b7ae2" + integrity sha512-aC2DGhBq5eEdyXWqrDInSqQjO0k8xtPRf5YylULqx8MCd6jBtzqfta/3ETMRpuKIc5hyswfO80ObyA1MvkCcUQ== + +"@babel/core@^7.24.5": + version "7.24.6" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.24.6.tgz#8650e0e4b03589ebe886c4e4a60398db0a7ec787" + integrity sha512-qAHSfAdVyFmIvl0VHELib8xar7ONuSHrE2hLnsaWkYNTI68dmi1x8GYDhJjMI/e7XWal9QBlZkwbOnkcw7Z8gQ== + dependencies: + "@ampproject/remapping" "^2.2.0" + "@babel/code-frame" "^7.24.6" + "@babel/generator" "^7.24.6" + "@babel/helper-compilation-targets" "^7.24.6" + "@babel/helper-module-transforms" "^7.24.6" + "@babel/helpers" "^7.24.6" + "@babel/parser" "^7.24.6" + "@babel/template" "^7.24.6" + "@babel/traverse" "^7.24.6" + "@babel/types" "^7.24.6" + convert-source-map "^2.0.0" + debug "^4.1.0" + gensync "^1.0.0-beta.2" + json5 "^2.2.3" + semver "^6.3.1" + +"@babel/generator@^7.24.6": + version "7.24.6" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.24.6.tgz#dfac82a228582a9d30c959fe50ad28951d4737a7" + integrity sha512-S7m4eNa6YAPJRHmKsLHIDJhNAGNKoWNiWefz1MBbpnt8g9lvMDl1hir4P9bo/57bQEmuwEhnRU/AMWsD0G/Fbg== + dependencies: + "@babel/types" "^7.24.6" + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.25" + jsesc "^2.5.1" + +"@babel/helper-compilation-targets@^7.24.6": + version "7.24.6" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.24.6.tgz#4a51d681f7680043d38e212715e2a7b1ad29cb51" + integrity sha512-VZQ57UsDGlX/5fFA7GkVPplZhHsVc+vuErWgdOiysI9Ksnw0Pbbd6pnPiR/mmJyKHgyIW0c7KT32gmhiF+cirg== + dependencies: + "@babel/compat-data" "^7.24.6" + "@babel/helper-validator-option" "^7.24.6" + browserslist "^4.22.2" + lru-cache "^5.1.1" + semver "^6.3.1" + +"@babel/helper-environment-visitor@^7.24.6": + version "7.24.6" + resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.24.6.tgz#ac7ad5517821641550f6698dd5468f8cef78620d" + integrity sha512-Y50Cg3k0LKLMjxdPjIl40SdJgMB85iXn27Vk/qbHZCFx/o5XO3PSnpi675h1KEmmDb6OFArfd5SCQEQ5Q4H88g== + +"@babel/helper-function-name@^7.24.6": + version "7.24.6" + resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.24.6.tgz#cebdd063386fdb95d511d84b117e51fc68fec0c8" + integrity sha512-xpeLqeeRkbxhnYimfr2PC+iA0Q7ljX/d1eZ9/inYbmfG2jpl8Lu3DyXvpOAnrS5kxkfOWJjioIMQsaMBXFI05w== + dependencies: + "@babel/template" "^7.24.6" + "@babel/types" "^7.24.6" + +"@babel/helper-hoist-variables@^7.24.6": + version "7.24.6" + resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.24.6.tgz#8a7ece8c26756826b6ffcdd0e3cf65de275af7f9" + integrity sha512-SF/EMrC3OD7dSta1bLJIlrsVxwtd0UpjRJqLno6125epQMJ/kyFmpTT4pbvPbdQHzCHg+biQ7Syo8lnDtbR+uA== + dependencies: + "@babel/types" "^7.24.6" + +"@babel/helper-module-imports@^7.24.6": + version "7.24.6" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.24.6.tgz#65e54ffceed6a268dc4ce11f0433b82cfff57852" + integrity sha512-a26dmxFJBF62rRO9mmpgrfTLsAuyHk4e1hKTUkD/fcMfynt8gvEKwQPQDVxWhca8dHoDck+55DFt42zV0QMw5g== + dependencies: + "@babel/types" "^7.24.6" + +"@babel/helper-module-transforms@^7.24.6": + version "7.24.6" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.24.6.tgz#22346ed9df44ce84dee850d7433c5b73fab1fe4e" + integrity sha512-Y/YMPm83mV2HJTbX1Qh2sjgjqcacvOlhbzdCCsSlblOKjSYmQqEbO6rUniWQyRo9ncyfjT8hnUjlG06RXDEmcA== + dependencies: + "@babel/helper-environment-visitor" "^7.24.6" + "@babel/helper-module-imports" "^7.24.6" + "@babel/helper-simple-access" "^7.24.6" + "@babel/helper-split-export-declaration" "^7.24.6" + "@babel/helper-validator-identifier" "^7.24.6" + +"@babel/helper-plugin-utils@^7.24.6": + version "7.24.6" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.6.tgz#fa02a32410a15a6e8f8185bcbf608f10528d2a24" + integrity sha512-MZG/JcWfxybKwsA9N9PmtF2lOSFSEMVCpIRrbxccZFLJPrJciJdG/UhSh5W96GEteJI2ARqm5UAHxISwRDLSNg== + +"@babel/helper-simple-access@^7.24.6": + version "7.24.6" + resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.24.6.tgz#1d6e04d468bba4fc963b4906f6dac6286cfedff1" + integrity sha512-nZzcMMD4ZhmB35MOOzQuiGO5RzL6tJbsT37Zx8M5L/i9KSrukGXWTjLe1knIbb/RmxoJE9GON9soq0c0VEMM5g== + dependencies: + "@babel/types" "^7.24.6" + +"@babel/helper-split-export-declaration@^7.24.6": + version "7.24.6" + resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.6.tgz#e830068f7ba8861c53b7421c284da30ae656d7a3" + integrity sha512-CvLSkwXGWnYlF9+J3iZUvwgAxKiYzK3BWuo+mLzD/MDGOZDj7Gq8+hqaOkMxmJwmlv0iu86uH5fdADd9Hxkymw== + dependencies: + "@babel/types" "^7.24.6" + +"@babel/helper-string-parser@^7.24.6": + version "7.24.6" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.24.6.tgz#28583c28b15f2a3339cfafafeaad42f9a0e828df" + integrity sha512-WdJjwMEkmBicq5T9fm/cHND3+UlFa2Yj8ALLgmoSQAJZysYbBjw+azChSGPN4DSPLXOcooGRvDwZWMcF/mLO2Q== + +"@babel/helper-validator-identifier@^7.24.6": + version "7.24.6" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.6.tgz#08bb6612b11bdec78f3feed3db196da682454a5e" + integrity sha512-4yA7s865JHaqUdRbnaxarZREuPTHrjpDT+pXoAZ1yhyo6uFnIEpS8VMu16siFOHDpZNKYv5BObhsB//ycbICyw== + +"@babel/helper-validator-option@^7.24.6": + version "7.24.6" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.24.6.tgz#59d8e81c40b7d9109ab7e74457393442177f460a" + integrity sha512-Jktc8KkF3zIkePb48QO+IapbXlSapOW9S+ogZZkcO6bABgYAxtZcjZ/O005111YLf+j4M84uEgwYoidDkXbCkQ== + +"@babel/helpers@^7.24.6": + version "7.24.6" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.24.6.tgz#cd124245299e494bd4e00edda0e4ea3545c2c176" + integrity sha512-V2PI+NqnyFu1i0GyTd/O/cTpxzQCYioSkUIRmgo7gFEHKKCg5w46+r/A6WeUR1+P3TeQ49dspGPNd/E3n9AnnA== + dependencies: + "@babel/template" "^7.24.6" + "@babel/types" "^7.24.6" + +"@babel/highlight@^7.24.6": + version "7.24.6" + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.24.6.tgz#6d610c1ebd2c6e061cade0153bf69b0590b7b3df" + integrity sha512-2YnuOp4HAk2BsBrJJvYCbItHx0zWscI1C3zgWkz+wDyD9I7GIVrfnLyrR4Y1VR+7p+chAEcrgRQYZAGIKMV7vQ== + dependencies: + "@babel/helper-validator-identifier" "^7.24.6" + chalk "^2.4.2" + js-tokens "^4.0.0" + picocolors "^1.0.0" + +"@babel/parser@^7.1.0", "@babel/parser@^7.20.7", "@babel/parser@^7.24.6": + version "7.24.6" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.24.6.tgz#5e030f440c3c6c78d195528c3b688b101a365328" + integrity sha512-eNZXdfU35nJC2h24RznROuOpO94h6x8sg9ju0tT9biNtLZ2vuP8SduLqqV+/8+cebSLV9SJEAN5Z3zQbJG/M+Q== + +"@babel/plugin-transform-react-jsx-self@^7.24.5": + version "7.24.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.24.6.tgz#4fa4870d594d6840d724d2006d0f98b19be6f502" + integrity sha512-FfZfHXtQ5jYPQsCRyLpOv2GeLIIJhs8aydpNh39vRDjhD411XcfWDni5i7OjP/Rs8GAtTn7sWFFELJSHqkIxYg== + dependencies: + "@babel/helper-plugin-utils" "^7.24.6" + +"@babel/plugin-transform-react-jsx-source@^7.24.1": + version "7.24.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.24.6.tgz#4e1503f24ca5fccb1fc7f20c57426899d5ce5c1f" + integrity sha512-BQTBCXmFRreU3oTUXcGKuPOfXAGb1liNY4AvvFKsOBAJ89RKcTsIrSsnMYkj59fNa66OFKnSa4AJZfy5Y4B9WA== + dependencies: + "@babel/helper-plugin-utils" "^7.24.6" + +"@babel/template@^7.24.6": + version "7.24.6" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.24.6.tgz#048c347b2787a6072b24c723664c8d02b67a44f9" + integrity sha512-3vgazJlLwNXi9jhrR1ef8qiB65L1RK90+lEQwv4OxveHnqC3BfmnHdgySwRLzf6akhlOYenT+b7AfWq+a//AHw== + dependencies: + "@babel/code-frame" "^7.24.6" + "@babel/parser" "^7.24.6" + "@babel/types" "^7.24.6" + +"@babel/traverse@^7.24.6": + version "7.24.6" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.24.6.tgz#0941ec50cdeaeacad0911eb67ae227a4f8424edc" + integrity sha512-OsNjaJwT9Zn8ozxcfoBc+RaHdj3gFmCmYoQLUII1o6ZrUwku0BMg80FoOTPx+Gi6XhcQxAYE4xyjPTo4SxEQqw== + dependencies: + "@babel/code-frame" "^7.24.6" + "@babel/generator" "^7.24.6" + "@babel/helper-environment-visitor" "^7.24.6" + "@babel/helper-function-name" "^7.24.6" + "@babel/helper-hoist-variables" "^7.24.6" + "@babel/helper-split-export-declaration" "^7.24.6" + "@babel/parser" "^7.24.6" + "@babel/types" "^7.24.6" + debug "^4.3.1" + globals "^11.1.0" + +"@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.24.6": + version "7.24.6" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.24.6.tgz#ba4e1f59870c10dc2fa95a274ac4feec23b21912" + integrity sha512-WaMsgi6Q8zMgMth93GvWPXkhAIEobfsIkLTacoVZoK1J0CevIPGYY2Vo5YvJGqyHqXM6P4ppOYGsIRU8MM9pFQ== + dependencies: + "@babel/helper-string-parser" "^7.24.6" + "@babel/helper-validator-identifier" "^7.24.6" + to-fast-properties "^2.0.0" + +"@esbuild/aix-ppc64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz#a70f4ac11c6a1dfc18b8bbb13284155d933b9537" + integrity sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g== + +"@esbuild/android-arm64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz#db1c9202a5bc92ea04c7b6840f1bbe09ebf9e6b9" + integrity sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg== + +"@esbuild/android-arm@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.20.2.tgz#3b488c49aee9d491c2c8f98a909b785870d6e995" + integrity sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w== + +"@esbuild/android-x64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.20.2.tgz#3b1628029e5576249d2b2d766696e50768449f98" + integrity sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg== + +"@esbuild/darwin-arm64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz#6e8517a045ddd86ae30c6608c8475ebc0c4000bb" + integrity sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA== + +"@esbuild/darwin-x64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz#90ed098e1f9dd8a9381695b207e1cff45540a0d0" + integrity sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA== + +"@esbuild/freebsd-arm64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz#d71502d1ee89a1130327e890364666c760a2a911" + integrity sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw== + +"@esbuild/freebsd-x64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz#aa5ea58d9c1dd9af688b8b6f63ef0d3d60cea53c" + integrity sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw== + +"@esbuild/linux-arm64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz#055b63725df678379b0f6db9d0fa85463755b2e5" + integrity sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A== + +"@esbuild/linux-arm@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz#76b3b98cb1f87936fbc37f073efabad49dcd889c" + integrity sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg== + +"@esbuild/linux-ia32@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz#c0e5e787c285264e5dfc7a79f04b8b4eefdad7fa" + integrity sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig== + +"@esbuild/linux-loong64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz#a6184e62bd7cdc63e0c0448b83801001653219c5" + integrity sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ== + +"@esbuild/linux-mips64el@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz#d08e39ce86f45ef8fc88549d29c62b8acf5649aa" + integrity sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA== + +"@esbuild/linux-ppc64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz#8d252f0b7756ffd6d1cbde5ea67ff8fd20437f20" + integrity sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg== + +"@esbuild/linux-riscv64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz#19f6dcdb14409dae607f66ca1181dd4e9db81300" + integrity sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg== + +"@esbuild/linux-s390x@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz#3c830c90f1a5d7dd1473d5595ea4ebb920988685" + integrity sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ== + +"@esbuild/linux-x64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz#86eca35203afc0d9de0694c64ec0ab0a378f6fff" + integrity sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw== + +"@esbuild/netbsd-x64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz#e771c8eb0e0f6e1877ffd4220036b98aed5915e6" + integrity sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ== + +"@esbuild/openbsd-x64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz#9a795ae4b4e37e674f0f4d716f3e226dd7c39baf" + integrity sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ== + +"@esbuild/sunos-x64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz#7df23b61a497b8ac189def6e25a95673caedb03f" + integrity sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w== + +"@esbuild/win32-arm64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz#f1ae5abf9ca052ae11c1bc806fb4c0f519bacf90" + integrity sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ== + +"@esbuild/win32-ia32@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz#241fe62c34d8e8461cd708277813e1d0ba55ce23" + integrity sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ== + +"@esbuild/win32-x64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz#9c907b21e30a52db959ba4f80bb01a0cc403d5cc" + integrity sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ== + +"@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0": + version "4.4.0" + resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz#a23514e8fb9af1269d5f7788aa556798d61c6b59" + integrity sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA== + dependencies: + eslint-visitor-keys "^3.3.0" + +"@eslint-community/regexpp@^4.10.0", "@eslint-community/regexpp@^4.6.1": + version "4.10.0" + resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.10.0.tgz#548f6de556857c8bb73bbee70c35dc82a2e74d63" + integrity sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA== + +"@eslint/eslintrc@^2.1.4": + version "2.1.4" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-2.1.4.tgz#388a269f0f25c1b6adc317b5a2c55714894c70ad" + integrity sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ== + dependencies: + ajv "^6.12.4" + debug "^4.3.2" + espree "^9.6.0" + globals "^13.19.0" + ignore "^5.2.0" + import-fresh "^3.2.1" + js-yaml "^4.1.0" + minimatch "^3.1.2" + strip-json-comments "^3.1.1" + +"@eslint/js@8.57.0": + version "8.57.0" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.57.0.tgz#a5417ae8427873f1dd08b70b3574b453e67b5f7f" + integrity sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g== + +"@humanwhocodes/config-array@^0.11.14": + version "0.11.14" + resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.14.tgz#d78e481a039f7566ecc9660b4ea7fe6b1fec442b" + integrity sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg== + dependencies: + "@humanwhocodes/object-schema" "^2.0.2" + debug "^4.3.1" + minimatch "^3.0.5" + +"@humanwhocodes/module-importer@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" + integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== + +"@humanwhocodes/object-schema@^2.0.2": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz#4a2868d75d6d6963e423bcf90b7fd1be343409d3" + integrity sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA== + +"@jridgewell/gen-mapping@^0.3.5": + version "0.3.5" + resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz#dcce6aff74bdf6dad1a95802b69b04a2fcb1fb36" + integrity sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg== + dependencies: + "@jridgewell/set-array" "^1.2.1" + "@jridgewell/sourcemap-codec" "^1.4.10" + "@jridgewell/trace-mapping" "^0.3.24" + +"@jridgewell/resolve-uri@^3.1.0": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" + integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== + +"@jridgewell/set-array@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.2.1.tgz#558fb6472ed16a4c850b889530e6b36438c49280" + integrity sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A== + +"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14": + version "1.4.15" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32" + integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg== + +"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25": + version "0.3.25" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz#15f190e98895f3fc23276ee14bc76b675c2e50f0" + integrity sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + +"@nodelib/fs.scandir@2.1.5": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" + integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g== + dependencies: + "@nodelib/fs.stat" "2.0.5" + run-parallel "^1.1.9" + +"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b" + integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== + +"@nodelib/fs.walk@^1.2.3", "@nodelib/fs.walk@^1.2.8": + version "1.2.8" + resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a" + integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== + dependencies: + "@nodelib/fs.scandir" "2.1.5" + fastq "^1.6.0" + +"@pkgr/core@^0.1.0": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.1.1.tgz#1ec17e2edbec25c8306d424ecfbf13c7de1aaa31" + integrity sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA== + +"@rollup/rollup-android-arm-eabi@4.18.0": + version "4.18.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.18.0.tgz#bbd0e616b2078cd2d68afc9824d1fadb2f2ffd27" + integrity sha512-Tya6xypR10giZV1XzxmH5wr25VcZSncG0pZIjfePT0OVBvqNEurzValetGNarVrGiq66EBVAFn15iYX4w6FKgQ== + +"@rollup/rollup-android-arm64@4.18.0": + version "4.18.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.18.0.tgz#97255ef6384c5f73f4800c0de91f5f6518e21203" + integrity sha512-avCea0RAP03lTsDhEyfy+hpfr85KfyTctMADqHVhLAF3MlIkq83CP8UfAHUssgXTYd+6er6PaAhx/QGv4L1EiA== + +"@rollup/rollup-darwin-arm64@4.18.0": + version "4.18.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.18.0.tgz#b6dd74e117510dfe94541646067b0545b42ff096" + integrity sha512-IWfdwU7KDSm07Ty0PuA/W2JYoZ4iTj3TUQjkVsO/6U+4I1jN5lcR71ZEvRh52sDOERdnNhhHU57UITXz5jC1/w== + +"@rollup/rollup-darwin-x64@4.18.0": + version "4.18.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.18.0.tgz#e07d76de1cec987673e7f3d48ccb8e106d42c05c" + integrity sha512-n2LMsUz7Ynu7DoQrSQkBf8iNrjOGyPLrdSg802vk6XT3FtsgX6JbE8IHRvposskFm9SNxzkLYGSq9QdpLYpRNA== + +"@rollup/rollup-linux-arm-gnueabihf@4.18.0": + version "4.18.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.18.0.tgz#9f1a6d218b560c9d75185af4b8bb42f9f24736b8" + integrity sha512-C/zbRYRXFjWvz9Z4haRxcTdnkPt1BtCkz+7RtBSuNmKzMzp3ZxdM28Mpccn6pt28/UWUCTXa+b0Mx1k3g6NOMA== + +"@rollup/rollup-linux-arm-musleabihf@4.18.0": + version "4.18.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.18.0.tgz#53618b92e6ffb642c7b620e6e528446511330549" + integrity sha512-l3m9ewPgjQSXrUMHg93vt0hYCGnrMOcUpTz6FLtbwljo2HluS4zTXFy2571YQbisTnfTKPZ01u/ukJdQTLGh9A== + +"@rollup/rollup-linux-arm64-gnu@4.18.0": + version "4.18.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.18.0.tgz#99a7ba5e719d4f053761a698f7b52291cefba577" + integrity sha512-rJ5D47d8WD7J+7STKdCUAgmQk49xuFrRi9pZkWoRD1UeSMakbcepWXPF8ycChBoAqs1pb2wzvbY6Q33WmN2ftw== + +"@rollup/rollup-linux-arm64-musl@4.18.0": + version "4.18.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.18.0.tgz#f53db99a45d9bc00ce94db8a35efa7c3c144a58c" + integrity sha512-be6Yx37b24ZwxQ+wOQXXLZqpq4jTckJhtGlWGZs68TgdKXJgw54lUUoFYrg6Zs/kjzAQwEwYbp8JxZVzZLRepQ== + +"@rollup/rollup-linux-powerpc64le-gnu@4.18.0": + version "4.18.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.18.0.tgz#cbb0837408fe081ce3435cf3730e090febafc9bf" + integrity sha512-hNVMQK+qrA9Todu9+wqrXOHxFiD5YmdEi3paj6vP02Kx1hjd2LLYR2eaN7DsEshg09+9uzWi2W18MJDlG0cxJA== + +"@rollup/rollup-linux-riscv64-gnu@4.18.0": + version "4.18.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.18.0.tgz#8ed09c1d1262ada4c38d791a28ae0fea28b80cc9" + integrity sha512-ROCM7i+m1NfdrsmvwSzoxp9HFtmKGHEqu5NNDiZWQtXLA8S5HBCkVvKAxJ8U+CVctHwV2Gb5VUaK7UAkzhDjlg== + +"@rollup/rollup-linux-s390x-gnu@4.18.0": + version "4.18.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.18.0.tgz#938138d3c8e0c96f022252a28441dcfb17afd7ec" + integrity sha512-0UyyRHyDN42QL+NbqevXIIUnKA47A+45WyasO+y2bGJ1mhQrfrtXUpTxCOrfxCR4esV3/RLYyucGVPiUsO8xjg== + +"@rollup/rollup-linux-x64-gnu@4.18.0": + version "4.18.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.18.0.tgz#1a7481137a54740bee1ded4ae5752450f155d942" + integrity sha512-xuglR2rBVHA5UsI8h8UbX4VJ470PtGCf5Vpswh7p2ukaqBGFTnsfzxUBetoWBWymHMxbIG0Cmx7Y9qDZzr648w== + +"@rollup/rollup-linux-x64-musl@4.18.0": + version "4.18.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.18.0.tgz#f1186afc601ac4f4fc25fac4ca15ecbee3a1874d" + integrity sha512-LKaqQL9osY/ir2geuLVvRRs+utWUNilzdE90TpyoX0eNqPzWjRm14oMEE+YLve4k/NAqCdPkGYDaDF5Sw+xBfg== + +"@rollup/rollup-win32-arm64-msvc@4.18.0": + version "4.18.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.18.0.tgz#ed6603e93636a96203c6915be4117245c1bd2daf" + integrity sha512-7J6TkZQFGo9qBKH0pk2cEVSRhJbL6MtfWxth7Y5YmZs57Pi+4x6c2dStAUvaQkHQLnEQv1jzBUW43GvZW8OFqA== + +"@rollup/rollup-win32-ia32-msvc@4.18.0": + version "4.18.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.18.0.tgz#14e0b404b1c25ebe6157a15edb9c46959ba74c54" + integrity sha512-Txjh+IxBPbkUB9+SXZMpv+b/vnTEtFyfWZgJ6iyCmt2tdx0OF5WhFowLmnh8ENGNpfUlUZkdI//4IEmhwPieNg== + +"@rollup/rollup-win32-x64-msvc@4.18.0": + version "4.18.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.18.0.tgz#5d694d345ce36b6ecf657349e03eb87297e68da4" + integrity sha512-UOo5FdvOL0+eIVTgS4tIdbW+TtnBLWg1YBCcU2KWM7nuNwRz9bksDX1bekJJCpu25N1DVWaCwnT39dVQxzqS8g== + +"@types/babel__core@^7.20.5": + version "7.20.5" + resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017" + integrity sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA== + dependencies: + "@babel/parser" "^7.20.7" + "@babel/types" "^7.20.7" + "@types/babel__generator" "*" + "@types/babel__template" "*" + "@types/babel__traverse" "*" + +"@types/babel__generator@*": + version "7.6.8" + resolved "https://registry.yarnpkg.com/@types/babel__generator/-/babel__generator-7.6.8.tgz#f836c61f48b1346e7d2b0d93c6dacc5b9535d3ab" + integrity sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw== + dependencies: + "@babel/types" "^7.0.0" + +"@types/babel__template@*": + version "7.4.4" + resolved "https://registry.yarnpkg.com/@types/babel__template/-/babel__template-7.4.4.tgz#5672513701c1b2199bc6dad636a9d7491586766f" + integrity sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A== + dependencies: + "@babel/parser" "^7.1.0" + "@babel/types" "^7.0.0" + +"@types/babel__traverse@*": + version "7.20.6" + resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.20.6.tgz#8dc9f0ae0f202c08d8d4dab648912c8d6038e3f7" + integrity sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg== + dependencies: + "@babel/types" "^7.20.7" + +"@types/estree@1.0.5": + version "1.0.5" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4" + integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw== + +"@types/prop-types@*": + version "15.7.12" + resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.12.tgz#12bb1e2be27293c1406acb6af1c3f3a1481d98c6" + integrity sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q== + +"@types/react-dom@^18": + version "18.3.0" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.3.0.tgz#0cbc818755d87066ab6ca74fbedb2547d74a82b0" + integrity sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg== + dependencies: + "@types/react" "*" + +"@types/react@*", "@types/react@^18": + version "18.3.3" + resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.3.tgz#9679020895318b0915d7a3ab004d92d33375c45f" + integrity sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw== + dependencies: + "@types/prop-types" "*" + csstype "^3.0.2" + +"@typescript-eslint/eslint-plugin@^7": + version "7.11.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.11.0.tgz#f90f0914657ead08e1c75f66939c926edeab42dd" + integrity sha512-P+qEahbgeHW4JQ/87FuItjBj8O3MYv5gELDzr8QaQ7fsll1gSMTYb6j87MYyxwf3DtD7uGFB9ShwgmCJB5KmaQ== + dependencies: + "@eslint-community/regexpp" "^4.10.0" + "@typescript-eslint/scope-manager" "7.11.0" + "@typescript-eslint/type-utils" "7.11.0" + "@typescript-eslint/utils" "7.11.0" + "@typescript-eslint/visitor-keys" "7.11.0" + graphemer "^1.4.0" + ignore "^5.3.1" + natural-compare "^1.4.0" + ts-api-utils "^1.3.0" + +"@typescript-eslint/parser@^7": + version "7.11.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-7.11.0.tgz#525ad8bee54a8f015f134edd241d91b84ab64839" + integrity sha512-yimw99teuaXVWsBcPO1Ais02kwJ1jmNA1KxE7ng0aT7ndr1pT1wqj0OJnsYVGKKlc4QJai86l/025L6z8CljOg== + dependencies: + "@typescript-eslint/scope-manager" "7.11.0" + "@typescript-eslint/types" "7.11.0" + "@typescript-eslint/typescript-estree" "7.11.0" + "@typescript-eslint/visitor-keys" "7.11.0" + debug "^4.3.4" + +"@typescript-eslint/scope-manager@7.11.0": + version "7.11.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-7.11.0.tgz#cf5619b01de62a226a59add15a02bde457335d1d" + integrity sha512-27tGdVEiutD4POirLZX4YzT180vevUURJl4wJGmm6TrQoiYwuxTIY98PBp6L2oN+JQxzE0URvYlzJaBHIekXAw== + dependencies: + "@typescript-eslint/types" "7.11.0" + "@typescript-eslint/visitor-keys" "7.11.0" + +"@typescript-eslint/type-utils@7.11.0": + version "7.11.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-7.11.0.tgz#ac216697d649084fedf4a910347b9642bd0ff099" + integrity sha512-WmppUEgYy+y1NTseNMJ6mCFxt03/7jTOy08bcg7bxJJdsM4nuhnchyBbE8vryveaJUf62noH7LodPSo5Z0WUCg== + dependencies: + "@typescript-eslint/typescript-estree" "7.11.0" + "@typescript-eslint/utils" "7.11.0" + debug "^4.3.4" + ts-api-utils "^1.3.0" + +"@typescript-eslint/types@7.11.0": + version "7.11.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-7.11.0.tgz#5e9702a5e8b424b7fc690e338d359939257d6722" + integrity sha512-MPEsDRZTyCiXkD4vd3zywDCifi7tatc4K37KqTprCvaXptP7Xlpdw0NR2hRJTetG5TxbWDB79Ys4kLmHliEo/w== + +"@typescript-eslint/typescript-estree@7.11.0": + version "7.11.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-7.11.0.tgz#7cbc569bc7336c3a494ceaf8204fdee5d5dbb7fa" + integrity sha512-cxkhZ2C/iyi3/6U9EPc5y+a6csqHItndvN/CzbNXTNrsC3/ASoYQZEt9uMaEp+xFNjasqQyszp5TumAVKKvJeQ== + dependencies: + "@typescript-eslint/types" "7.11.0" + "@typescript-eslint/visitor-keys" "7.11.0" + debug "^4.3.4" + globby "^11.1.0" + is-glob "^4.0.3" + minimatch "^9.0.4" + semver "^7.6.0" + ts-api-utils "^1.3.0" + +"@typescript-eslint/utils@7.11.0": + version "7.11.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-7.11.0.tgz#524f047f2209959424c3ef689b0d83b3bc09919c" + integrity sha512-xlAWwPleNRHwF37AhrZurOxA1wyXowW4PqVXZVUNCLjB48CqdPJoJWkrpH2nij9Q3Lb7rtWindtoXwxjxlKKCA== + dependencies: + "@eslint-community/eslint-utils" "^4.4.0" + "@typescript-eslint/scope-manager" "7.11.0" + "@typescript-eslint/types" "7.11.0" + "@typescript-eslint/typescript-estree" "7.11.0" + +"@typescript-eslint/visitor-keys@7.11.0": + version "7.11.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-7.11.0.tgz#2c50cd292e67645eec05ac0830757071b4a4d597" + integrity sha512-7syYk4MzjxTEk0g/w3iqtgxnFQspDJfn6QKD36xMuuhTzjcxY7F8EmBLnALjVyaOF1/bVocu3bS/2/F7rXrveQ== + dependencies: + "@typescript-eslint/types" "7.11.0" + eslint-visitor-keys "^3.4.3" + +"@ungap/structured-clone@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406" + integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ== + +"@vitejs/plugin-react@^4.2": + version "4.3.0" + resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-4.3.0.tgz#f20ec2369a92d8abaaefa60da8b7157819d20481" + integrity sha512-KcEbMsn4Dpk+LIbHMj7gDPRKaTMStxxWRkRmxsg/jVdFdJCZWt1SchZcf0M4t8lIKdwwMsEyzhrcOXRrDPtOBw== + dependencies: + "@babel/core" "^7.24.5" + "@babel/plugin-transform-react-jsx-self" "^7.24.5" + "@babel/plugin-transform-react-jsx-source" "^7.24.1" + "@types/babel__core" "^7.20.5" + react-refresh "^0.14.2" + +acorn-jsx@^5.3.2: + version "5.3.2" + resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" + integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== + +acorn@^8.9.0: + version "8.11.3" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.11.3.tgz#71e0b14e13a4ec160724b38fb7b0f233b1b81d7a" + integrity sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg== + +ajv@^6.12.4: + version "6.12.6" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" + integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== + dependencies: + fast-deep-equal "^3.1.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-styles@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" + integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== + dependencies: + color-convert "^1.9.0" + +ansi-styles@^4.1.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + +array-buffer-byte-length@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz#1e5583ec16763540a27ae52eed99ff899223568f" + integrity sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg== + dependencies: + call-bind "^1.0.5" + is-array-buffer "^3.0.4" + +array-includes@^3.1.6, array-includes@^3.1.8: + version "3.1.8" + resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.8.tgz#5e370cbe172fdd5dd6530c1d4aadda25281ba97d" + integrity sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.2" + es-object-atoms "^1.0.0" + get-intrinsic "^1.2.4" + is-string "^1.0.7" + +array-union@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d" + integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== + +array.prototype.findlast@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz#3e4fbcb30a15a7f5bf64cf2faae22d139c2e4904" + integrity sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.2" + es-errors "^1.3.0" + es-object-atoms "^1.0.0" + es-shim-unscopables "^1.0.2" + +array.prototype.flat@^1.3.1: + version "1.3.2" + resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz#1476217df8cff17d72ee8f3ba06738db5b387d18" + integrity sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA== + dependencies: + call-bind "^1.0.2" + define-properties "^1.2.0" + es-abstract "^1.22.1" + es-shim-unscopables "^1.0.0" + +array.prototype.flatmap@^1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz#c9a7c6831db8e719d6ce639190146c24bbd3e527" + integrity sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ== + dependencies: + call-bind "^1.0.2" + define-properties "^1.2.0" + es-abstract "^1.22.1" + es-shim-unscopables "^1.0.0" + +array.prototype.toreversed@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/array.prototype.toreversed/-/array.prototype.toreversed-1.1.2.tgz#b989a6bf35c4c5051e1dc0325151bf8088954eba" + integrity sha512-wwDCoT4Ck4Cz7sLtgUmzR5UV3YF5mFHUlbChCzZBQZ+0m2cl/DH3tKgvphv1nKgFsJ48oCSg6p91q2Vm0I/ZMA== + dependencies: + call-bind "^1.0.2" + define-properties "^1.2.0" + es-abstract "^1.22.1" + es-shim-unscopables "^1.0.0" + +array.prototype.tosorted@^1.1.3: + version "1.1.4" + resolved "https://registry.yarnpkg.com/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz#fe954678ff53034e717ea3352a03f0b0b86f7ffc" + integrity sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.3" + es-errors "^1.3.0" + es-shim-unscopables "^1.0.2" + +arraybuffer.prototype.slice@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz#097972f4255e41bc3425e37dc3f6421cf9aefde6" + integrity sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A== + dependencies: + array-buffer-byte-length "^1.0.1" + call-bind "^1.0.5" + define-properties "^1.2.1" + es-abstract "^1.22.3" + es-errors "^1.2.1" + get-intrinsic "^1.2.3" + is-array-buffer "^3.0.4" + is-shared-array-buffer "^1.0.2" + +available-typed-arrays@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz#a5cc375d6a03c2efc87a553f3e0b1522def14846" + integrity sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ== + dependencies: + possible-typed-array-names "^1.0.0" + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" + integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +brace-expansion@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" + integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== + dependencies: + balanced-match "^1.0.0" + +braces@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" + integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== + dependencies: + fill-range "^7.1.1" + +browserslist@^4.22.2: + version "4.23.0" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.23.0.tgz#8f3acc2bbe73af7213399430890f86c63a5674ab" + integrity sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ== + dependencies: + caniuse-lite "^1.0.30001587" + electron-to-chromium "^1.4.668" + node-releases "^2.0.14" + update-browserslist-db "^1.0.13" + +call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.6, call-bind@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9" + integrity sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + set-function-length "^1.2.1" + +callsites@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" + integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== + +caniuse-lite@^1.0.30001587: + version "1.0.30001627" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001627.tgz#8071c42d468e06ed2fb2c545efe79a663fd326ab" + integrity sha512-4zgNiB8nTyV/tHhwZrFs88ryjls/lHiqFhrxCW4qSTeuRByBVnPYpDInchOIySWknznucaf31Z4KYqjfbrecVw== + +chalk@^2.4.2: + version "2.4.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" + integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== + dependencies: + ansi-styles "^3.2.1" + escape-string-regexp "^1.0.5" + supports-color "^5.3.0" + +chalk@^4.0.0: + version "4.1.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +color-convert@^1.9.0: + version "1.9.3" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" + integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== + dependencies: + color-name "1.1.3" + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" + integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== + +convert-source-map@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" + integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== + +cross-spawn@^7.0.2: + version "7.0.3" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" + integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +csstype@^3.0.2: + version "3.1.3" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81" + integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw== + +data-view-buffer@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/data-view-buffer/-/data-view-buffer-1.0.1.tgz#8ea6326efec17a2e42620696e671d7d5a8bc66b2" + integrity sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA== + dependencies: + call-bind "^1.0.6" + es-errors "^1.3.0" + is-data-view "^1.0.1" + +data-view-byte-length@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz#90721ca95ff280677eb793749fce1011347669e2" + integrity sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ== + dependencies: + call-bind "^1.0.7" + es-errors "^1.3.0" + is-data-view "^1.0.1" + +data-view-byte-offset@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz#5e0bbfb4828ed2d1b9b400cd8a7d119bca0ff18a" + integrity sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA== + dependencies: + call-bind "^1.0.6" + es-errors "^1.3.0" + is-data-view "^1.0.1" + +debug@^4.1.0, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4: + version "4.3.5" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.5.tgz#e83444eceb9fedd4a1da56d671ae2446a01a6e1e" + integrity sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg== + dependencies: + ms "2.1.2" + +deep-is@^0.1.3: + version "0.1.4" + resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" + integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== + +define-data-property@^1.0.1, define-data-property@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" + integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + gopd "^1.0.1" + +define-properties@^1.2.0, define-properties@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.2.1.tgz#10781cc616eb951a80a034bafcaa7377f6af2b6c" + integrity sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg== + dependencies: + define-data-property "^1.0.1" + has-property-descriptors "^1.0.0" + object-keys "^1.1.1" + +detect-indent@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-7.0.1.tgz#cbb060a12842b9c4d333f1cac4aa4da1bb66bc25" + integrity sha512-Mc7QhQ8s+cLrnUfU/Ji94vG/r8M26m8f++vyres4ZoojaRDpZ1eSIh/EpzLNwlWuvzSZ3UbDFspjFvTDXe6e/g== + +detect-newline@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-4.0.1.tgz#fcefdb5713e1fb8cb2839b8b6ee22e6716ab8f23" + integrity sha512-qE3Veg1YXzGHQhlA6jzebZN2qVf6NX+A7m7qlhCGG30dJixrAQhYOsJjsnBjJkCSmuOPpCk30145fr8FV0bzog== + +dir-glob@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" + integrity sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA== + dependencies: + path-type "^4.0.0" + +doctrine@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d" + integrity sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw== + dependencies: + esutils "^2.0.2" + +doctrine@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961" + integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w== + dependencies: + esutils "^2.0.2" + +electron-to-chromium@^1.4.668: + version "1.4.788" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.788.tgz#a3545959d5cfa0a266d3e551386c040be34e7e06" + integrity sha512-ubp5+Ev/VV8KuRoWnfP2QF2Bg+O2ZFdb49DiiNbz2VmgkIqrnyYaqIOqj8A6K/3p1xV0QcU5hBQ1+BmB6ot1OA== + +es-abstract@^1.22.1, es-abstract@^1.22.3, es-abstract@^1.23.0, es-abstract@^1.23.1, es-abstract@^1.23.2, es-abstract@^1.23.3: + version "1.23.3" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.23.3.tgz#8f0c5a35cd215312573c5a27c87dfd6c881a0aa0" + integrity sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A== + dependencies: + array-buffer-byte-length "^1.0.1" + arraybuffer.prototype.slice "^1.0.3" + available-typed-arrays "^1.0.7" + call-bind "^1.0.7" + data-view-buffer "^1.0.1" + data-view-byte-length "^1.0.1" + data-view-byte-offset "^1.0.0" + es-define-property "^1.0.0" + es-errors "^1.3.0" + es-object-atoms "^1.0.0" + es-set-tostringtag "^2.0.3" + es-to-primitive "^1.2.1" + function.prototype.name "^1.1.6" + get-intrinsic "^1.2.4" + get-symbol-description "^1.0.2" + globalthis "^1.0.3" + gopd "^1.0.1" + has-property-descriptors "^1.0.2" + has-proto "^1.0.3" + has-symbols "^1.0.3" + hasown "^2.0.2" + internal-slot "^1.0.7" + is-array-buffer "^3.0.4" + is-callable "^1.2.7" + is-data-view "^1.0.1" + is-negative-zero "^2.0.3" + is-regex "^1.1.4" + is-shared-array-buffer "^1.0.3" + is-string "^1.0.7" + is-typed-array "^1.1.13" + is-weakref "^1.0.2" + object-inspect "^1.13.1" + object-keys "^1.1.1" + object.assign "^4.1.5" + regexp.prototype.flags "^1.5.2" + safe-array-concat "^1.1.2" + safe-regex-test "^1.0.3" + string.prototype.trim "^1.2.9" + string.prototype.trimend "^1.0.8" + string.prototype.trimstart "^1.0.8" + typed-array-buffer "^1.0.2" + typed-array-byte-length "^1.0.1" + typed-array-byte-offset "^1.0.2" + typed-array-length "^1.0.6" + unbox-primitive "^1.0.2" + which-typed-array "^1.1.15" + +es-define-property@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.0.tgz#c7faefbdff8b2696cf5f46921edfb77cc4ba3845" + integrity sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ== + dependencies: + get-intrinsic "^1.2.4" + +es-errors@^1.2.1, es-errors@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" + integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== + +es-iterator-helpers@^1.0.19: + version "1.0.19" + resolved "https://registry.yarnpkg.com/es-iterator-helpers/-/es-iterator-helpers-1.0.19.tgz#117003d0e5fec237b4b5c08aded722e0c6d50ca8" + integrity sha512-zoMwbCcH5hwUkKJkT8kDIBZSz9I6mVG//+lDCinLCGov4+r7NIy0ld8o03M0cJxl2spVf6ESYVS6/gpIfq1FFw== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.3" + es-errors "^1.3.0" + es-set-tostringtag "^2.0.3" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + globalthis "^1.0.3" + has-property-descriptors "^1.0.2" + has-proto "^1.0.3" + has-symbols "^1.0.3" + internal-slot "^1.0.7" + iterator.prototype "^1.1.2" + safe-array-concat "^1.1.2" + +es-object-atoms@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.0.0.tgz#ddb55cd47ac2e240701260bc2a8e31ecb643d941" + integrity sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw== + dependencies: + es-errors "^1.3.0" + +es-set-tostringtag@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz#8bb60f0a440c2e4281962428438d58545af39777" + integrity sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ== + dependencies: + get-intrinsic "^1.2.4" + has-tostringtag "^1.0.2" + hasown "^2.0.1" + +es-shim-unscopables@^1.0.0, es-shim-unscopables@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz#1f6942e71ecc7835ed1c8a83006d8771a63a3763" + integrity sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw== + dependencies: + hasown "^2.0.0" + +es-to-primitive@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.1.tgz#e55cd4c9cdc188bcefb03b366c736323fc5c898a" + integrity sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA== + dependencies: + is-callable "^1.1.4" + is-date-object "^1.0.1" + is-symbol "^1.0.2" + +esbuild@^0.20.1: + version "0.20.2" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.20.2.tgz#9d6b2386561766ee6b5a55196c6d766d28c87ea1" + integrity sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g== + optionalDependencies: + "@esbuild/aix-ppc64" "0.20.2" + "@esbuild/android-arm" "0.20.2" + "@esbuild/android-arm64" "0.20.2" + "@esbuild/android-x64" "0.20.2" + "@esbuild/darwin-arm64" "0.20.2" + "@esbuild/darwin-x64" "0.20.2" + "@esbuild/freebsd-arm64" "0.20.2" + "@esbuild/freebsd-x64" "0.20.2" + "@esbuild/linux-arm" "0.20.2" + "@esbuild/linux-arm64" "0.20.2" + "@esbuild/linux-ia32" "0.20.2" + "@esbuild/linux-loong64" "0.20.2" + "@esbuild/linux-mips64el" "0.20.2" + "@esbuild/linux-ppc64" "0.20.2" + "@esbuild/linux-riscv64" "0.20.2" + "@esbuild/linux-s390x" "0.20.2" + "@esbuild/linux-x64" "0.20.2" + "@esbuild/netbsd-x64" "0.20.2" + "@esbuild/openbsd-x64" "0.20.2" + "@esbuild/sunos-x64" "0.20.2" + "@esbuild/win32-arm64" "0.20.2" + "@esbuild/win32-ia32" "0.20.2" + "@esbuild/win32-x64" "0.20.2" + +escalade@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.2.tgz#54076e9ab29ea5bf3d8f1ed62acffbb88272df27" + integrity sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA== + +escape-string-regexp@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" + integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg== + +escape-string-regexp@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + +eslint-plugin-react-hooks@^4.6: + version "4.6.2" + resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz#c829eb06c0e6f484b3fbb85a97e57784f328c596" + integrity sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ== + +eslint-plugin-react-refresh@^0.4.7: + version "0.4.7" + resolved "https://registry.yarnpkg.com/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.7.tgz#1f597f9093b254f10ee0961c139a749acb19af7d" + integrity sha512-yrj+KInFmwuQS2UQcg1SF83ha1tuHC1jMQbRNyuWtlEzzKRDgAl7L4Yp4NlDUZTZNlWvHEzOtJhMi40R7JxcSw== + +eslint-plugin-react@^7.34: + version "7.34.2" + resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.34.2.tgz#2780a1a35a51aca379d86d29b9a72adc6bfe6b66" + integrity sha512-2HCmrU+/JNigDN6tg55cRDKCQWicYAPB38JGSFDQt95jDm8rrvSUo7YPkOIm5l6ts1j1zCvysNcasvfTMQzUOw== + dependencies: + array-includes "^3.1.8" + array.prototype.findlast "^1.2.5" + array.prototype.flatmap "^1.3.2" + array.prototype.toreversed "^1.1.2" + array.prototype.tosorted "^1.1.3" + doctrine "^2.1.0" + es-iterator-helpers "^1.0.19" + estraverse "^5.3.0" + jsx-ast-utils "^2.4.1 || ^3.0.0" + minimatch "^3.1.2" + object.entries "^1.1.8" + object.fromentries "^2.0.8" + object.hasown "^1.1.4" + object.values "^1.2.0" + prop-types "^15.8.1" + resolve "^2.0.0-next.5" + semver "^6.3.1" + string.prototype.matchall "^4.0.11" + +eslint-scope@^7.2.2: + version "7.2.2" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-7.2.2.tgz#deb4f92563390f32006894af62a22dba1c46423f" + integrity sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg== + dependencies: + esrecurse "^4.3.0" + estraverse "^5.2.0" + +eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4.3: + version "3.4.3" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" + integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== + +eslint@^8: + version "8.57.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.57.0.tgz#c786a6fd0e0b68941aaf624596fb987089195668" + integrity sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ== + dependencies: + "@eslint-community/eslint-utils" "^4.2.0" + "@eslint-community/regexpp" "^4.6.1" + "@eslint/eslintrc" "^2.1.4" + "@eslint/js" "8.57.0" + "@humanwhocodes/config-array" "^0.11.14" + "@humanwhocodes/module-importer" "^1.0.1" + "@nodelib/fs.walk" "^1.2.8" + "@ungap/structured-clone" "^1.2.0" + ajv "^6.12.4" + chalk "^4.0.0" + cross-spawn "^7.0.2" + debug "^4.3.2" + doctrine "^3.0.0" + escape-string-regexp "^4.0.0" + eslint-scope "^7.2.2" + eslint-visitor-keys "^3.4.3" + espree "^9.6.1" + esquery "^1.4.2" + esutils "^2.0.2" + fast-deep-equal "^3.1.3" + file-entry-cache "^6.0.1" + find-up "^5.0.0" + glob-parent "^6.0.2" + globals "^13.19.0" + graphemer "^1.4.0" + ignore "^5.2.0" + imurmurhash "^0.1.4" + is-glob "^4.0.0" + is-path-inside "^3.0.3" + js-yaml "^4.1.0" + json-stable-stringify-without-jsonify "^1.0.1" + levn "^0.4.1" + lodash.merge "^4.6.2" + minimatch "^3.1.2" + natural-compare "^1.4.0" + optionator "^0.9.3" + strip-ansi "^6.0.1" + text-table "^0.2.0" + +espree@^9.6.0, espree@^9.6.1: + version "9.6.1" + resolved "https://registry.yarnpkg.com/espree/-/espree-9.6.1.tgz#a2a17b8e434690a5432f2f8018ce71d331a48c6f" + integrity sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ== + dependencies: + acorn "^8.9.0" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^3.4.1" + +esquery@^1.4.2: + version "1.5.0" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.5.0.tgz#6ce17738de8577694edd7361c57182ac8cb0db0b" + integrity sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg== + dependencies: + estraverse "^5.1.0" + +esrecurse@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" + integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== + dependencies: + estraverse "^5.2.0" + +estraverse@^5.1.0, estraverse@^5.2.0, estraverse@^5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" + integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== + +esutils@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" + integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== + +fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" + integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== + +fast-glob@^3.2.9, fast-glob@^3.3.0: + version "3.3.2" + resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.2.tgz#a904501e57cfdd2ffcded45e99a54fef55e46129" + integrity sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow== + dependencies: + "@nodelib/fs.stat" "^2.0.2" + "@nodelib/fs.walk" "^1.2.3" + glob-parent "^5.1.2" + merge2 "^1.3.0" + micromatch "^4.0.4" + +fast-json-stable-stringify@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" + integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== + +fast-levenshtein@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" + integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== + +fastq@^1.6.0: + version "1.17.1" + resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.17.1.tgz#2a523f07a4e7b1e81a42b91b8bf2254107753b47" + integrity sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w== + dependencies: + reusify "^1.0.4" + +file-entry-cache@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027" + integrity sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg== + dependencies: + flat-cache "^3.0.4" + +fill-range@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" + integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== + dependencies: + to-regex-range "^5.0.1" + +find-up@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + dependencies: + locate-path "^6.0.0" + path-exists "^4.0.0" + +flat-cache@^3.0.4: + version "3.2.0" + resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.2.0.tgz#2c0c2d5040c99b1632771a9d105725c0115363ee" + integrity sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw== + dependencies: + flatted "^3.2.9" + keyv "^4.5.3" + rimraf "^3.0.2" + +flatted@^3.2.9: + version "3.3.1" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.1.tgz#21db470729a6734d4997002f439cb308987f567a" + integrity sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw== + +for-each@^0.3.3: + version "0.3.3" + resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e" + integrity sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw== + dependencies: + is-callable "^1.1.3" + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== + +fsevents@~2.3.2, fsevents@~2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + +function.prototype.name@^1.1.5, function.prototype.name@^1.1.6: + version "1.1.6" + resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.6.tgz#cdf315b7d90ee77a4c6ee216c3c3362da07533fd" + integrity sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg== + dependencies: + call-bind "^1.0.2" + define-properties "^1.2.0" + es-abstract "^1.22.1" + functions-have-names "^1.2.3" + +functions-have-names@^1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" + integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== + +gensync@^1.0.0-beta.2: + version "1.0.0-beta.2" + resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" + integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== + +get-intrinsic@^1.1.3, get-intrinsic@^1.2.1, get-intrinsic@^1.2.3, get-intrinsic@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.4.tgz#e385f5a4b5227d449c3eabbad05494ef0abbeadd" + integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + has-proto "^1.0.1" + has-symbols "^1.0.3" + hasown "^2.0.0" + +get-stdin@^9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-9.0.0.tgz#3983ff82e03d56f1b2ea0d3e60325f39d703a575" + integrity sha512-dVKBjfWisLAicarI2Sf+JuBE/DghV4UzNAVe9yhEJuzeREd3JhOTE9cUaJTeSa77fsbQUK3pcOpJfM59+VKZaA== + +get-symbol-description@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.0.2.tgz#533744d5aa20aca4e079c8e5daf7fd44202821f5" + integrity sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg== + dependencies: + call-bind "^1.0.5" + es-errors "^1.3.0" + get-intrinsic "^1.2.4" + +git-hooks-list@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/git-hooks-list/-/git-hooks-list-3.1.0.tgz#386dc531dcc17474cf094743ff30987a3d3e70fc" + integrity sha512-LF8VeHeR7v+wAbXqfgRlTSX/1BJR9Q1vEMR8JAz1cEg6GX07+zyj3sAdDvYjj/xnlIfVuGgj4qBei1K3hKH+PA== + +glob-parent@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" + integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== + dependencies: + is-glob "^4.0.1" + +glob-parent@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" + integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== + dependencies: + is-glob "^4.0.3" + +glob@^7.1.3: + version "7.2.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" + integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.1.1" + once "^1.3.0" + path-is-absolute "^1.0.0" + +globals@^11.1.0: + version "11.12.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" + integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== + +globals@^13.19.0: + version "13.24.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-13.24.0.tgz#8432a19d78ce0c1e833949c36adb345400bb1171" + integrity sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ== + dependencies: + type-fest "^0.20.2" + +globalthis@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/globalthis/-/globalthis-1.0.4.tgz#7430ed3a975d97bfb59bcce41f5cabbafa651236" + integrity sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ== + dependencies: + define-properties "^1.2.1" + gopd "^1.0.1" + +globby@^11.1.0: + version "11.1.0" + resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b" + integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g== + dependencies: + array-union "^2.1.0" + dir-glob "^3.0.1" + fast-glob "^3.2.9" + ignore "^5.2.0" + merge2 "^1.4.1" + slash "^3.0.0" + +globby@^13.1.2: + version "13.2.2" + resolved "https://registry.yarnpkg.com/globby/-/globby-13.2.2.tgz#63b90b1bf68619c2135475cbd4e71e66aa090592" + integrity sha512-Y1zNGV+pzQdh7H39l9zgB4PJqjRNqydvdYCDG4HFXM4XuvSaQQlEc91IU1yALL8gUTDomgBAfz3XJdmUS+oo0w== + dependencies: + dir-glob "^3.0.1" + fast-glob "^3.3.0" + ignore "^5.2.4" + merge2 "^1.4.1" + slash "^4.0.0" + +gopd@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c" + integrity sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA== + dependencies: + get-intrinsic "^1.1.3" + +graphemer@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" + integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== + +has-bigints@^1.0.1, has-bigints@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.2.tgz#0871bd3e3d51626f6ca0966668ba35d5602d6eaa" + integrity sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ== + +has-flag@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" + integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw== + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + +has-property-descriptors@^1.0.0, has-property-descriptors@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854" + integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== + dependencies: + es-define-property "^1.0.0" + +has-proto@^1.0.1, has-proto@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.3.tgz#b31ddfe9b0e6e9914536a6ab286426d0214f77fd" + integrity sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q== + +has-symbols@^1.0.2, has-symbols@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" + integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== + +has-tostringtag@^1.0.0, has-tostringtag@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc" + integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw== + dependencies: + has-symbols "^1.0.3" + +hasown@^2.0.0, hasown@^2.0.1, hasown@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" + integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== + dependencies: + function-bind "^1.1.2" + +ignore@^5.2.0, ignore@^5.2.4, ignore@^5.3.1: + version "5.3.1" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.1.tgz#5073e554cd42c5b33b394375f538b8593e34d4ef" + integrity sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw== + +import-fresh@^3.2.1: + version "3.3.0" + resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" + integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw== + dependencies: + parent-module "^1.0.0" + resolve-from "^4.0.0" + +imurmurhash@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" + integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +internal-slot@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.7.tgz#c06dcca3ed874249881007b0a5523b172a190802" + integrity sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g== + dependencies: + es-errors "^1.3.0" + hasown "^2.0.0" + side-channel "^1.0.4" + +is-array-buffer@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/is-array-buffer/-/is-array-buffer-3.0.4.tgz#7a1f92b3d61edd2bc65d24f130530ea93d7fae98" + integrity sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw== + dependencies: + call-bind "^1.0.2" + get-intrinsic "^1.2.1" + +is-async-function@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-async-function/-/is-async-function-2.0.0.tgz#8e4418efd3e5d3a6ebb0164c05ef5afb69aa9646" + integrity sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA== + dependencies: + has-tostringtag "^1.0.0" + +is-bigint@^1.0.1: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.0.4.tgz#08147a1875bc2b32005d41ccd8291dffc6691df3" + integrity sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg== + dependencies: + has-bigints "^1.0.1" + +is-boolean-object@^1.1.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.1.2.tgz#5c6dc200246dd9321ae4b885a114bb1f75f63719" + integrity sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA== + dependencies: + call-bind "^1.0.2" + has-tostringtag "^1.0.0" + +is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.2.7: + version "1.2.7" + resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055" + integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== + +is-core-module@^2.13.0: + version "2.13.1" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.13.1.tgz#ad0d7532c6fea9da1ebdc82742d74525c6273384" + integrity sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw== + dependencies: + hasown "^2.0.0" + +is-data-view@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-data-view/-/is-data-view-1.0.1.tgz#4b4d3a511b70f3dc26d42c03ca9ca515d847759f" + integrity sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w== + dependencies: + is-typed-array "^1.1.13" + +is-date-object@^1.0.1, is-date-object@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.5.tgz#0841d5536e724c25597bf6ea62e1bd38298df31f" + integrity sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ== + dependencies: + has-tostringtag "^1.0.0" + +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + +is-finalizationregistry@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-finalizationregistry/-/is-finalizationregistry-1.0.2.tgz#c8749b65f17c133313e661b1289b95ad3dbd62e6" + integrity sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw== + dependencies: + call-bind "^1.0.2" + +is-generator-function@^1.0.10: + version "1.0.10" + resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.0.10.tgz#f1558baf1ac17e0deea7c0415c438351ff2b3c72" + integrity sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A== + dependencies: + has-tostringtag "^1.0.0" + +is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +is-map@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.3.tgz#ede96b7fe1e270b3c4465e3a465658764926d62e" + integrity sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw== + +is-negative-zero@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.3.tgz#ced903a027aca6381b777a5743069d7376a49747" + integrity sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw== + +is-number-object@^1.0.4: + version "1.0.7" + resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.0.7.tgz#59d50ada4c45251784e9904f5246c742f07a42fc" + integrity sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ== + dependencies: + has-tostringtag "^1.0.0" + +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + +is-path-inside@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" + integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== + +is-plain-obj@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz#d65025edec3657ce032fd7db63c97883eaed71f0" + integrity sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg== + +is-regex@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958" + integrity sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg== + dependencies: + call-bind "^1.0.2" + has-tostringtag "^1.0.0" + +is-set@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-set/-/is-set-2.0.3.tgz#8ab209ea424608141372ded6e0cb200ef1d9d01d" + integrity sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg== + +is-shared-array-buffer@^1.0.2, is-shared-array-buffer@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz#1237f1cba059cdb62431d378dcc37d9680181688" + integrity sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg== + dependencies: + call-bind "^1.0.7" + +is-string@^1.0.5, is-string@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.7.tgz#0dd12bf2006f255bb58f695110eff7491eebc0fd" + integrity sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg== + dependencies: + has-tostringtag "^1.0.0" + +is-symbol@^1.0.2, is-symbol@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.4.tgz#a6dac93b635b063ca6872236de88910a57af139c" + integrity sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg== + dependencies: + has-symbols "^1.0.2" + +is-typed-array@^1.1.13: + version "1.1.13" + resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.13.tgz#d6c5ca56df62334959322d7d7dd1cca50debe229" + integrity sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw== + dependencies: + which-typed-array "^1.1.14" + +is-weakmap@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/is-weakmap/-/is-weakmap-2.0.2.tgz#bf72615d649dfe5f699079c54b83e47d1ae19cfd" + integrity sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w== + +is-weakref@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-weakref/-/is-weakref-1.0.2.tgz#9529f383a9338205e89765e0392efc2f100f06f2" + integrity sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ== + dependencies: + call-bind "^1.0.2" + +is-weakset@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-weakset/-/is-weakset-2.0.3.tgz#e801519df8c0c43e12ff2834eead84ec9e624007" + integrity sha512-LvIm3/KWzS9oRFHugab7d+M/GcBXuXX5xZkzPmN+NxihdQlZUQ4dWuSV1xR/sq6upL1TJEDrfBgRepHFdBtSNQ== + dependencies: + call-bind "^1.0.7" + get-intrinsic "^1.2.4" + +isarray@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723" + integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw== + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + +iterator.prototype@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/iterator.prototype/-/iterator.prototype-1.1.2.tgz#5e29c8924f01916cb9335f1ff80619dcff22b0c0" + integrity sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w== + dependencies: + define-properties "^1.2.1" + get-intrinsic "^1.2.1" + has-symbols "^1.0.3" + reflect.getprototypeof "^1.0.4" + set-function-name "^2.0.1" + +"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" + integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + +js-yaml@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" + integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== + dependencies: + argparse "^2.0.1" + +jsesc@^2.5.1: + version "2.5.2" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" + integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA== + +json-buffer@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" + integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== + +json-schema-traverse@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" + integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== + +json-stable-stringify-without-jsonify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" + integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== + +json5@^2.2.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" + integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== + +"jsx-ast-utils@^2.4.1 || ^3.0.0": + version "3.3.5" + resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz#4766bd05a8e2a11af222becd19e15575e52a853a" + integrity sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ== + dependencies: + array-includes "^3.1.6" + array.prototype.flat "^1.3.1" + object.assign "^4.1.4" + object.values "^1.1.6" + +keyv@^4.5.3: + version "4.5.4" + resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" + integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== + dependencies: + json-buffer "3.0.1" + +levn@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" + integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== + dependencies: + prelude-ls "^1.2.1" + type-check "~0.4.0" + +locate-path@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" + integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== + dependencies: + p-locate "^5.0.0" + +lodash.merge@^4.6.2: + version "4.6.2" + resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" + integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== + +loose-envify@^1.1.0, loose-envify@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" + integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== + dependencies: + js-tokens "^3.0.0 || ^4.0.0" + +lru-cache@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" + integrity sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w== + dependencies: + yallist "^3.0.2" + +merge2@^1.3.0, merge2@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" + integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== + +micromatch@^4.0.4: + version "4.0.7" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.7.tgz#33e8190d9fe474a9895525f5618eee136d46c2e5" + integrity sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q== + dependencies: + braces "^3.0.3" + picomatch "^2.3.1" + +minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + dependencies: + brace-expansion "^1.1.7" + +minimatch@^9.0.4: + version "9.0.4" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.4.tgz#8e49c731d1749cbec05050ee5145147b32496a51" + integrity sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw== + dependencies: + brace-expansion "^2.0.1" + +ms@2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" + integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== + +nanoid@^3.3.7: + version "3.3.7" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8" + integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g== + +natural-compare@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" + integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== + +node-releases@^2.0.14: + version "2.0.14" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.14.tgz#2ffb053bceb8b2be8495ece1ab6ce600c4461b0b" + integrity sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw== + +object-assign@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" + integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== + +object-inspect@^1.13.1: + version "1.13.1" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.1.tgz#b96c6109324ccfef6b12216a956ca4dc2ff94bc2" + integrity sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ== + +object-keys@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" + integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== + +object.assign@^4.1.4, object.assign@^4.1.5: + version "4.1.5" + resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.5.tgz#3a833f9ab7fdb80fc9e8d2300c803d216d8fdbb0" + integrity sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ== + dependencies: + call-bind "^1.0.5" + define-properties "^1.2.1" + has-symbols "^1.0.3" + object-keys "^1.1.1" + +object.entries@^1.1.8: + version "1.1.8" + resolved "https://registry.yarnpkg.com/object.entries/-/object.entries-1.1.8.tgz#bffe6f282e01f4d17807204a24f8edd823599c41" + integrity sha512-cmopxi8VwRIAw/fkijJohSfpef5PdN0pMQJN6VC/ZKvn0LIknWD8KtgY6KlQdEc4tIjcQ3HxSMmnvtzIscdaYQ== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + +object.fromentries@^2.0.8: + version "2.0.8" + resolved "https://registry.yarnpkg.com/object.fromentries/-/object.fromentries-2.0.8.tgz#f7195d8a9b97bd95cbc1999ea939ecd1a2b00c65" + integrity sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.2" + es-object-atoms "^1.0.0" + +object.hasown@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/object.hasown/-/object.hasown-1.1.4.tgz#e270ae377e4c120cdcb7656ce66884a6218283dc" + integrity sha512-FZ9LZt9/RHzGySlBARE3VF+gE26TxR38SdmqOqliuTnl9wrKulaQs+4dee1V+Io8VfxqzAfHu6YuRgUy8OHoTg== + dependencies: + define-properties "^1.2.1" + es-abstract "^1.23.2" + es-object-atoms "^1.0.0" + +object.values@^1.1.6, object.values@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.2.0.tgz#65405a9d92cee68ac2d303002e0b8470a4d9ab1b" + integrity sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + +once@^1.3.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== + dependencies: + wrappy "1" + +optionator@^0.9.3: + version "0.9.4" + resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734" + integrity sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g== + dependencies: + deep-is "^0.1.3" + fast-levenshtein "^2.0.6" + levn "^0.4.1" + prelude-ls "^1.2.1" + type-check "^0.4.0" + word-wrap "^1.2.5" + +p-limit@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== + dependencies: + yocto-queue "^0.1.0" + +p-locate@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" + integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== + dependencies: + p-limit "^3.0.2" + +parent-module@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" + integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== + dependencies: + callsites "^3.0.0" + +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== + +path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + +path-parse@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" + integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== + +path-type@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" + integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== + +picocolors@^1.0.0, picocolors@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.1.tgz#a8ad579b571952f0e5d25892de5445bcfe25aaa1" + integrity sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew== + +picomatch@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + +possible-typed-array-names@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz#89bb63c6fada2c3e90adc4a647beeeb39cc7bf8f" + integrity sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q== + +postcss@^8.4.38: + version "8.4.38" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.38.tgz#b387d533baf2054288e337066d81c6bee9db9e0e" + integrity sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A== + dependencies: + nanoid "^3.3.7" + picocolors "^1.0.0" + source-map-js "^1.2.0" + +prelude-ls@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" + integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== + +prettier-plugin-organize-imports@^3.2: + version "3.2.4" + resolved "https://registry.yarnpkg.com/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-3.2.4.tgz#77967f69d335e9c8e6e5d224074609309c62845e" + integrity sha512-6m8WBhIp0dfwu0SkgfOxJqh+HpdyfqSSLfKKRZSFbDuEQXDDndb8fTpRWkUrX/uBenkex3MgnVk0J3b3Y5byog== + +prettier-plugin-packagejson@^2.5: + version "2.5.0" + resolved "https://registry.yarnpkg.com/prettier-plugin-packagejson/-/prettier-plugin-packagejson-2.5.0.tgz#23d2cb8b1f7840702d35e3a5078e564ea0bc63e0" + integrity sha512-6XkH3rpin5QEQodBSVNg+rBo4r91g/1mCaRwS1YGdQJZ6jwqrg2UchBsIG9tpS1yK1kNBvOt84OILsX8uHzBGg== + dependencies: + sort-package-json "2.10.0" + synckit "0.9.0" + +prettier@^3: + version "3.3.0" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.3.0.tgz#d173ea0524a691d4c0b1181752f2b46724328cdf" + integrity sha512-J9odKxERhCQ10OC2yb93583f6UnYutOeiV5i0zEDS7UGTdUt0u+y8erxl3lBKvwo/JHyyoEdXjwp4dke9oyZ/g== + +prop-types@^15.8.1: + version "15.8.1" + resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" + integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== + dependencies: + loose-envify "^1.4.0" + object-assign "^4.1.1" + react-is "^16.13.1" + +punycode@^2.1.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" + integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== + +queue-microtask@^1.2.2: + version "1.2.3" + resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" + integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== + +react-dom@^18: + version "18.3.1" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.3.1.tgz#c2265d79511b57d479b3dd3fdfa51536494c5cb4" + integrity sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw== + dependencies: + loose-envify "^1.1.0" + scheduler "^0.23.2" + +react-is@^16.13.1: + version "16.13.1" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" + integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== + +react-refresh@^0.14.2: + version "0.14.2" + resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.2.tgz#3833da01ce32da470f1f936b9d477da5c7028bf9" + integrity sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA== + +react@^18: + version "18.3.1" + resolved "https://registry.yarnpkg.com/react/-/react-18.3.1.tgz#49ab892009c53933625bd16b2533fc754cab2891" + integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ== + dependencies: + loose-envify "^1.1.0" + +reflect.getprototypeof@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.6.tgz#3ab04c32a8390b770712b7a8633972702d278859" + integrity sha512-fmfw4XgoDke3kdI6h4xcUz1dG8uaiv5q9gcEwLS4Pnth2kxT+GZ7YehS1JTMGBQmtV7Y4GFGbs2re2NqhdozUg== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.1" + es-errors "^1.3.0" + get-intrinsic "^1.2.4" + globalthis "^1.0.3" + which-builtin-type "^1.1.3" + +regexp.prototype.flags@^1.5.2: + version "1.5.2" + resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz#138f644a3350f981a858c44f6bb1a61ff59be334" + integrity sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw== + dependencies: + call-bind "^1.0.6" + define-properties "^1.2.1" + es-errors "^1.3.0" + set-function-name "^2.0.1" + +resolve-from@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" + integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== + +resolve@^2.0.0-next.5: + version "2.0.0-next.5" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-2.0.0-next.5.tgz#6b0ec3107e671e52b68cd068ef327173b90dc03c" + integrity sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA== + dependencies: + is-core-module "^2.13.0" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + +reusify@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" + integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== + +rimraf@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" + integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== + dependencies: + glob "^7.1.3" + +rollup@^4.13.0: + version "4.18.0" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.18.0.tgz#497f60f0c5308e4602cf41136339fbf87d5f5dda" + integrity sha512-QmJz14PX3rzbJCN1SG4Xe/bAAX2a6NpCP8ab2vfu2GiUr8AQcr2nCV/oEO3yneFarB67zk8ShlIyWb2LGTb3Sg== + dependencies: + "@types/estree" "1.0.5" + optionalDependencies: + "@rollup/rollup-android-arm-eabi" "4.18.0" + "@rollup/rollup-android-arm64" "4.18.0" + "@rollup/rollup-darwin-arm64" "4.18.0" + "@rollup/rollup-darwin-x64" "4.18.0" + "@rollup/rollup-linux-arm-gnueabihf" "4.18.0" + "@rollup/rollup-linux-arm-musleabihf" "4.18.0" + "@rollup/rollup-linux-arm64-gnu" "4.18.0" + "@rollup/rollup-linux-arm64-musl" "4.18.0" + "@rollup/rollup-linux-powerpc64le-gnu" "4.18.0" + "@rollup/rollup-linux-riscv64-gnu" "4.18.0" + "@rollup/rollup-linux-s390x-gnu" "4.18.0" + "@rollup/rollup-linux-x64-gnu" "4.18.0" + "@rollup/rollup-linux-x64-musl" "4.18.0" + "@rollup/rollup-win32-arm64-msvc" "4.18.0" + "@rollup/rollup-win32-ia32-msvc" "4.18.0" + "@rollup/rollup-win32-x64-msvc" "4.18.0" + fsevents "~2.3.2" + +run-parallel@^1.1.9: + version "1.2.0" + resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" + integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA== + dependencies: + queue-microtask "^1.2.2" + +safe-array-concat@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/safe-array-concat/-/safe-array-concat-1.1.2.tgz#81d77ee0c4e8b863635227c721278dd524c20edb" + integrity sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q== + dependencies: + call-bind "^1.0.7" + get-intrinsic "^1.2.4" + has-symbols "^1.0.3" + isarray "^2.0.5" + +safe-regex-test@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.0.3.tgz#a5b4c0f06e0ab50ea2c395c14d8371232924c377" + integrity sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw== + dependencies: + call-bind "^1.0.6" + es-errors "^1.3.0" + is-regex "^1.1.4" + +scheduler@^0.23.2: + version "0.23.2" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.2.tgz#414ba64a3b282892e944cf2108ecc078d115cdc3" + integrity sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ== + dependencies: + loose-envify "^1.1.0" + +semver@^6.3.1: + version "6.3.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" + integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== + +semver@^7.6.0: + version "7.6.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.2.tgz#1e3b34759f896e8f14d6134732ce798aeb0c6e13" + integrity sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w== + +set-function-length@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" + integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== + dependencies: + define-data-property "^1.1.4" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + gopd "^1.0.1" + has-property-descriptors "^1.0.2" + +set-function-name@^2.0.1, set-function-name@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/set-function-name/-/set-function-name-2.0.2.tgz#16a705c5a0dc2f5e638ca96d8a8cd4e1c2b90985" + integrity sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ== + dependencies: + define-data-property "^1.1.4" + es-errors "^1.3.0" + functions-have-names "^1.2.3" + has-property-descriptors "^1.0.2" + +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +side-channel@^1.0.4, side-channel@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2" + integrity sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA== + dependencies: + call-bind "^1.0.7" + es-errors "^1.3.0" + get-intrinsic "^1.2.4" + object-inspect "^1.13.1" + +slash@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" + integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== + +slash@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-4.0.0.tgz#2422372176c4c6c5addb5e2ada885af984b396a7" + integrity sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew== + +sort-object-keys@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/sort-object-keys/-/sort-object-keys-1.1.3.tgz#bff833fe85cab147b34742e45863453c1e190b45" + integrity sha512-855pvK+VkU7PaKYPc+Jjnmt4EzejQHyhhF33q31qG8x7maDzkeFhAAThdCYay11CISO+qAMwjOBP+fPZe0IPyg== + +sort-package-json@2.10.0: + version "2.10.0" + resolved "https://registry.yarnpkg.com/sort-package-json/-/sort-package-json-2.10.0.tgz#6be07424bf3b7db9fbb1bdd69e7945f301026d8a" + integrity sha512-MYecfvObMwJjjJskhxYfuOADkXp1ZMMnCFC8yhp+9HDsk7HhR336hd7eiBs96lTXfiqmUNI+WQCeCMRBhl251g== + dependencies: + detect-indent "^7.0.1" + detect-newline "^4.0.0" + get-stdin "^9.0.0" + git-hooks-list "^3.0.0" + globby "^13.1.2" + is-plain-obj "^4.1.0" + semver "^7.6.0" + sort-object-keys "^1.1.3" + +source-map-js@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.0.tgz#16b809c162517b5b8c3e7dcd315a2a5c2612b2af" + integrity sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg== + +string.prototype.matchall@^4.0.11: + version "4.0.11" + resolved "https://registry.yarnpkg.com/string.prototype.matchall/-/string.prototype.matchall-4.0.11.tgz#1092a72c59268d2abaad76582dccc687c0297e0a" + integrity sha512-NUdh0aDavY2og7IbBPenWqR9exH+E26Sv8e0/eTe1tltDGZL+GtBkDAnnyBtmekfK6/Dq3MkcGtzXFEd1LQrtg== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.2" + es-errors "^1.3.0" + es-object-atoms "^1.0.0" + get-intrinsic "^1.2.4" + gopd "^1.0.1" + has-symbols "^1.0.3" + internal-slot "^1.0.7" + regexp.prototype.flags "^1.5.2" + set-function-name "^2.0.2" + side-channel "^1.0.6" + +string.prototype.trim@^1.2.9: + version "1.2.9" + resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz#b6fa326d72d2c78b6df02f7759c73f8f6274faa4" + integrity sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.0" + es-object-atoms "^1.0.0" + +string.prototype.trimend@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.8.tgz#3651b8513719e8a9f48de7f2f77640b26652b229" + integrity sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + +string.prototype.trimstart@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz#7ee834dda8c7c17eff3118472bb35bfedaa34dde" + integrity sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + +strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-json-comments@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" + integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== + +supports-color@^5.3.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" + integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== + dependencies: + has-flag "^3.0.0" + +supports-color@^7.1.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + +supports-preserve-symlinks-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" + integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== + +synckit@0.9.0: + version "0.9.0" + resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.9.0.tgz#5b33b458b3775e4466a5b377fba69c63572ae449" + integrity sha512-7RnqIMq572L8PeEzKeBINYEJDDxpcH8JEgLwUqBd3TkofhFRbkq4QLR0u+36avGAhCRbk2nnmjcW9SE531hPDg== + dependencies: + "@pkgr/core" "^0.1.0" + tslib "^2.6.2" + +text-table@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" + integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== + +to-fast-properties@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" + integrity sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog== + +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + +ts-api-utils@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.3.0.tgz#4b490e27129f1e8e686b45cc4ab63714dc60eea1" + integrity sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ== + +tslib@^2.6.2: + version "2.6.2" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" + integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== + +type-check@^0.4.0, type-check@~0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" + integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== + dependencies: + prelude-ls "^1.2.1" + +type-fest@^0.20.2: + version "0.20.2" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" + integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== + +typed-array-buffer@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz#1867c5d83b20fcb5ccf32649e5e2fc7424474ff3" + integrity sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ== + dependencies: + call-bind "^1.0.7" + es-errors "^1.3.0" + is-typed-array "^1.1.13" + +typed-array-byte-length@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz#d92972d3cff99a3fa2e765a28fcdc0f1d89dec67" + integrity sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw== + dependencies: + call-bind "^1.0.7" + for-each "^0.3.3" + gopd "^1.0.1" + has-proto "^1.0.3" + is-typed-array "^1.1.13" + +typed-array-byte-offset@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/typed-array-byte-offset/-/typed-array-byte-offset-1.0.2.tgz#f9ec1acb9259f395093e4567eb3c28a580d02063" + integrity sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA== + dependencies: + available-typed-arrays "^1.0.7" + call-bind "^1.0.7" + for-each "^0.3.3" + gopd "^1.0.1" + has-proto "^1.0.3" + is-typed-array "^1.1.13" + +typed-array-length@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/typed-array-length/-/typed-array-length-1.0.6.tgz#57155207c76e64a3457482dfdc1c9d1d3c4c73a3" + integrity sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g== + dependencies: + call-bind "^1.0.7" + for-each "^0.3.3" + gopd "^1.0.1" + has-proto "^1.0.3" + is-typed-array "^1.1.13" + possible-typed-array-names "^1.0.0" + +typescript@^5: + version "5.4.5" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.4.5.tgz#42ccef2c571fdbd0f6718b1d1f5e6e5ef006f611" + integrity sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ== + +unbox-primitive@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e" + integrity sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw== + dependencies: + call-bind "^1.0.2" + has-bigints "^1.0.2" + has-symbols "^1.0.3" + which-boxed-primitive "^1.0.2" + +update-browserslist-db@^1.0.13: + version "1.0.16" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.16.tgz#f6d489ed90fb2f07d67784eb3f53d7891f736356" + integrity sha512-KVbTxlBYlckhF5wgfyZXTWnMn7MMZjMu9XG8bPlliUOP9ThaF4QnhP8qrjrH7DRzHfSk0oQv1wToW+iA5GajEQ== + dependencies: + escalade "^3.1.2" + picocolors "^1.0.1" + +uri-js@^4.2.2: + version "4.4.1" + resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" + integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== + dependencies: + punycode "^2.1.0" + +vite@^5.2: + version "5.2.12" + resolved "https://registry.yarnpkg.com/vite/-/vite-5.2.12.tgz#3536c93c58ba18edea4915a2ac573e6537409d97" + integrity sha512-/gC8GxzxMK5ntBwb48pR32GGhENnjtY30G4A0jemunsBkiEZFw60s8InGpN8gkhHEkjnRK1aSAxeQgwvFhUHAA== + dependencies: + esbuild "^0.20.1" + postcss "^8.4.38" + rollup "^4.13.0" + optionalDependencies: + fsevents "~2.3.3" + +which-boxed-primitive@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6" + integrity sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg== + dependencies: + is-bigint "^1.0.1" + is-boolean-object "^1.1.0" + is-number-object "^1.0.4" + is-string "^1.0.5" + is-symbol "^1.0.3" + +which-builtin-type@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/which-builtin-type/-/which-builtin-type-1.1.3.tgz#b1b8443707cc58b6e9bf98d32110ff0c2cbd029b" + integrity sha512-YmjsSMDBYsM1CaFiayOVT06+KJeXf0o5M/CAd4o1lTadFAtacTUM49zoYxr/oroopFDfhvN6iEcBxUyc3gvKmw== + dependencies: + function.prototype.name "^1.1.5" + has-tostringtag "^1.0.0" + is-async-function "^2.0.0" + is-date-object "^1.0.5" + is-finalizationregistry "^1.0.2" + is-generator-function "^1.0.10" + is-regex "^1.1.4" + is-weakref "^1.0.2" + isarray "^2.0.5" + which-boxed-primitive "^1.0.2" + which-collection "^1.0.1" + which-typed-array "^1.1.9" + +which-collection@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/which-collection/-/which-collection-1.0.2.tgz#627ef76243920a107e7ce8e96191debe4b16c2a0" + integrity sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw== + dependencies: + is-map "^2.0.3" + is-set "^2.0.3" + is-weakmap "^2.0.2" + is-weakset "^2.0.3" + +which-typed-array@^1.1.14, which-typed-array@^1.1.15, which-typed-array@^1.1.9: + version "1.1.15" + resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.15.tgz#264859e9b11a649b388bfaaf4f767df1f779b38d" + integrity sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA== + dependencies: + available-typed-arrays "^1.0.7" + call-bind "^1.0.7" + for-each "^0.3.3" + gopd "^1.0.1" + has-tostringtag "^1.0.2" + +which@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +word-wrap@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" + integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== + +yallist@^3.0.2: + version "3.1.1" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" + integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== + +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +zod@^3: + version "3.23.8" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d" + integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g== diff --git a/mobile/README.md b/mobile/README.md index 6d86ad5344..bd254b00ba 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -46,7 +46,7 @@ You can alternatively install the build from PlayStore or F-Droid. ## 🧑‍💻 Building from source -1. [Install Flutter v3.22.0](https://flutter.dev/docs/get-started/install). +1. [Install Flutter v3.22.2](https://flutter.dev/docs/get-started/install). 2. Pull in all submodules with `git submodule update --init --recursive` diff --git a/mobile/android/app/src/dev/res/mipmap-hdpi/launcher_icon.png b/mobile/android/app/src/dev/res/mipmap-hdpi/launcher_icon.png new file mode 100644 index 0000000000..730f69093a Binary files /dev/null and b/mobile/android/app/src/dev/res/mipmap-hdpi/launcher_icon.png differ diff --git a/mobile/android/app/src/dev/res/mipmap-mdpi/launcher_icon.png b/mobile/android/app/src/dev/res/mipmap-mdpi/launcher_icon.png new file mode 100644 index 0000000000..4938651ea7 Binary files /dev/null and b/mobile/android/app/src/dev/res/mipmap-mdpi/launcher_icon.png differ diff --git a/mobile/android/app/src/dev/res/mipmap-xhdpi/launcher_icon.png b/mobile/android/app/src/dev/res/mipmap-xhdpi/launcher_icon.png new file mode 100644 index 0000000000..4c5336733d Binary files /dev/null and b/mobile/android/app/src/dev/res/mipmap-xhdpi/launcher_icon.png differ diff --git a/mobile/android/app/src/dev/res/mipmap-xxhdpi/launcher_icon.png b/mobile/android/app/src/dev/res/mipmap-xxhdpi/launcher_icon.png new file mode 100644 index 0000000000..6d804c6329 Binary files /dev/null and b/mobile/android/app/src/dev/res/mipmap-xxhdpi/launcher_icon.png differ diff --git a/mobile/android/app/src/dev/res/mipmap-xxxhdpi/launcher_icon.png b/mobile/android/app/src/dev/res/mipmap-xxxhdpi/launcher_icon.png new file mode 100644 index 0000000000..149bfa0486 Binary files /dev/null and b/mobile/android/app/src/dev/res/mipmap-xxxhdpi/launcher_icon.png differ diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index de64e53eb8..832b28c685 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -1,7 +1,9 @@ - + + + + + + + + + + + + diff --git a/mobile/assets/video-editor/video-crop-original-action.svg b/mobile/assets/video-editor/video-crop-original-action.svg new file mode 100644 index 0000000000..9d754363f7 --- /dev/null +++ b/mobile/assets/video-editor/video-crop-original-action.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/mobile/assets/video-editor/video-crop-ratio_16_9-action.svg b/mobile/assets/video-editor/video-crop-ratio_16_9-action.svg new file mode 100644 index 0000000000..82f8ca975f --- /dev/null +++ b/mobile/assets/video-editor/video-crop-ratio_16_9-action.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/mobile/assets/video-editor/video-crop-ratio_1_1-action.svg b/mobile/assets/video-editor/video-crop-ratio_1_1-action.svg new file mode 100644 index 0000000000..5785c5e547 --- /dev/null +++ b/mobile/assets/video-editor/video-crop-ratio_1_1-action.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/mobile/assets/video-editor/video-crop-ratio_3_4-action.svg b/mobile/assets/video-editor/video-crop-ratio_3_4-action.svg new file mode 100644 index 0000000000..d6ffd9a02f --- /dev/null +++ b/mobile/assets/video-editor/video-crop-ratio_3_4-action.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/mobile/assets/video-editor/video-crop-ratio_4_3-action.svg b/mobile/assets/video-editor/video-crop-ratio_4_3-action.svg new file mode 100644 index 0000000000..ab173fb7d1 --- /dev/null +++ b/mobile/assets/video-editor/video-crop-ratio_4_3-action.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/mobile/assets/video-editor/video-crop-ratio_9_16-action.svg b/mobile/assets/video-editor/video-crop-ratio_9_16-action.svg new file mode 100644 index 0000000000..c52e98fdb2 --- /dev/null +++ b/mobile/assets/video-editor/video-crop-ratio_9_16-action.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/mobile/assets/video-editor/video-editor-crop-action.svg b/mobile/assets/video-editor/video-editor-crop-action.svg new file mode 100644 index 0000000000..0b713cb641 --- /dev/null +++ b/mobile/assets/video-editor/video-editor-crop-action.svg @@ -0,0 +1,4 @@ + + + + diff --git a/mobile/assets/video-editor/video-editor-rotate-action.svg b/mobile/assets/video-editor/video-editor-rotate-action.svg new file mode 100644 index 0000000000..d143654d20 --- /dev/null +++ b/mobile/assets/video-editor/video-editor-rotate-action.svg @@ -0,0 +1,3 @@ + + + diff --git a/mobile/assets/video-editor/video-editor-trim-action.svg b/mobile/assets/video-editor/video-editor-trim-action.svg new file mode 100644 index 0000000000..c59bc03a90 --- /dev/null +++ b/mobile/assets/video-editor/video-editor-trim-action.svg @@ -0,0 +1,3 @@ + + + diff --git a/mobile/fastlane/metadata/android/id/short_description.txt b/mobile/fastlane/metadata/android/id/short_description.txt new file mode 100644 index 0000000000..7d594c243d --- /dev/null +++ b/mobile/fastlane/metadata/android/id/short_description.txt @@ -0,0 +1 @@ +ente adalah app penyimpanan foto dengan enkripsi ujung ke ujung \ No newline at end of file diff --git a/mobile/fastlane/metadata/android/id/title.txt b/mobile/fastlane/metadata/android/id/title.txt new file mode 100644 index 0000000000..80c7aca32d --- /dev/null +++ b/mobile/fastlane/metadata/android/id/title.txt @@ -0,0 +1 @@ +ente - penyimpanan foto terenkripsi \ No newline at end of file diff --git a/mobile/fastlane/metadata/ios/id/keywords.txt b/mobile/fastlane/metadata/ios/id/keywords.txt new file mode 100644 index 0000000000..0a56ad5d21 --- /dev/null +++ b/mobile/fastlane/metadata/ios/id/keywords.txt @@ -0,0 +1 @@ +foto,fotografi,keluarga,privasi,cloud,pencadangan,video,enkripsi,penyimpanan,album,alternatif diff --git a/mobile/fastlane/metadata/ios/id/name.txt b/mobile/fastlane/metadata/ios/id/name.txt new file mode 100644 index 0000000000..ad1c680d42 --- /dev/null +++ b/mobile/fastlane/metadata/ios/id/name.txt @@ -0,0 +1 @@ +ente Photos diff --git a/mobile/fastlane/metadata/ios/id/subtitle.txt b/mobile/fastlane/metadata/ios/id/subtitle.txt new file mode 100644 index 0000000000..de5c2e51f4 --- /dev/null +++ b/mobile/fastlane/metadata/ios/id/subtitle.txt @@ -0,0 +1 @@ +Penyimpanan foto terenkripsi diff --git a/mobile/fastlane/metadata/playstore/id/short_description.txt b/mobile/fastlane/metadata/playstore/id/short_description.txt new file mode 100644 index 0000000000..bb459898e0 --- /dev/null +++ b/mobile/fastlane/metadata/playstore/id/short_description.txt @@ -0,0 +1 @@ +Penyimpanan foto terenkripsi - cadangkan, rapikan, dan bagikan foto dan videomu \ No newline at end of file diff --git a/mobile/fastlane/metadata/playstore/id/title.txt b/mobile/fastlane/metadata/playstore/id/title.txt new file mode 100644 index 0000000000..e2a7bd5a2a --- /dev/null +++ b/mobile/fastlane/metadata/playstore/id/title.txt @@ -0,0 +1 @@ +ente Photos \ No newline at end of file diff --git a/mobile/flutter_launcher_icons-dev.yaml b/mobile/flutter_launcher_icons-dev.yaml new file mode 100644 index 0000000000..67360d981f --- /dev/null +++ b/mobile/flutter_launcher_icons-dev.yaml @@ -0,0 +1,4 @@ +flutter_launcher_icons: + android: "launcher_icon" + ios: true + image_path: "assets/launcher_icon/ente-icon-dev.png" \ No newline at end of file diff --git a/mobile/ios/Podfile b/mobile/ios/Podfile index c9d26e696c..0993f733e7 100644 --- a/mobile/ios/Podfile +++ b/mobile/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -platform :ios, '12.0' +platform :ios, '12.1' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 9f74d552a7..8463f904c4 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -10,6 +10,13 @@ PODS: - Flutter - device_info_plus (0.0.1): - Flutter + - ffmpeg-kit-ios-min (6.0) + - ffmpeg_kit_flutter_min (6.0.3): + - ffmpeg_kit_flutter_min/min (= 6.0.3) + - Flutter + - ffmpeg_kit_flutter_min/min (6.0.3): + - ffmpeg-kit-ios-min (= 6.0) + - Flutter - file_saver (0.0.1): - Flutter - Firebase/CoreOnly (10.24.0): @@ -105,6 +112,8 @@ PODS: - Flutter - image_editor_common (1.0.0): - Flutter + - image_picker_ios (0.0.1): + - Flutter - in_app_purchase_storekit (0.0.1): - Flutter - FlutterMacOS @@ -230,6 +239,7 @@ DEPENDENCIES: - connectivity_plus (from `.symlinks/plugins/connectivity_plus/darwin`) - dart_ui_isolate (from `.symlinks/plugins/dart_ui_isolate/ios`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) + - ffmpeg_kit_flutter_min (from `.symlinks/plugins/ffmpeg_kit_flutter_min/ios`) - file_saver (from `.symlinks/plugins/file_saver/ios`) - firebase_core (from `.symlinks/plugins/firebase_core/ios`) - firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`) @@ -245,6 +255,7 @@ DEPENDENCIES: - fluttertoast (from `.symlinks/plugins/fluttertoast/ios`) - home_widget (from `.symlinks/plugins/home_widget/ios`) - image_editor_common (from `.symlinks/plugins/image_editor_common/ios`) + - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - in_app_purchase_storekit (from `.symlinks/plugins/in_app_purchase_storekit/darwin`) - integration_test (from `.symlinks/plugins/integration_test/ios`) - local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`) @@ -277,6 +288,7 @@ DEPENDENCIES: SPEC REPOS: trunk: + - ffmpeg-kit-ios-min - Firebase - FirebaseCore - FirebaseCoreInternal @@ -309,6 +321,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/dart_ui_isolate/ios" device_info_plus: :path: ".symlinks/plugins/device_info_plus/ios" + ffmpeg_kit_flutter_min: + :path: ".symlinks/plugins/ffmpeg_kit_flutter_min/ios" file_saver: :path: ".symlinks/plugins/file_saver/ios" firebase_core: @@ -339,6 +353,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/home_widget/ios" image_editor_common: :path: ".symlinks/plugins/image_editor_common/ios" + image_picker_ios: + :path: ".symlinks/plugins/image_picker_ios/ios" in_app_purchase_storekit: :path: ".symlinks/plugins/in_app_purchase_storekit/darwin" integration_test: @@ -404,6 +420,8 @@ SPEC CHECKSUMS: connectivity_plus: ddd7f30999e1faaef5967c23d5b6d503d10434db dart_ui_isolate: d5bcda83ca4b04f129d70eb90110b7a567aece14 device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6 + ffmpeg-kit-ios-min: 4e9a088f4ee9629435960b9d68e54848975f1931 + ffmpeg_kit_flutter_min: 5eff47f4965bf9d1150e98961eb6129f5ae3f28c file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808 Firebase: 91fefd38712feb9186ea8996af6cbdef41473442 firebase_core: 66b99b4fb4e5d7cc4e88d4c195fe986681f3466a @@ -426,6 +444,7 @@ SPEC CHECKSUMS: GoogleUtilities: d053d902a8edaa9904e1bd00c37535385b8ed152 home_widget: 0434835a4c9a75704264feff6be17ea40e0f0d57 image_editor_common: d6f6644ae4a6de80481e89fe6d0a8c49e30b4b43 + image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 in_app_purchase_storekit: 0e4b3c2e43ba1e1281f4f46dd71b0593ce529892 integration_test: ce0a3ffa1de96d1a89ca0ac26fca7ea18a749ef4 libwebp: 1786c9f4ff8a279e4dac1e8f385004d5fc253009 @@ -456,7 +475,7 @@ SPEC CHECKSUMS: Sentry: ebc12276bd17613a114ab359074096b6b3725203 sentry_flutter: 88ebea3f595b0bc16acc5bedacafe6d60c12dcd5 SentryPrivate: d651efb234cf385ec9a1cdd3eff94b5e78a0e0fe - share_plus: c3fef564749587fc939ef86ffb283ceac0baf9f5 + share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695 sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec sqlite3: 02d1f07eaaa01f80a1c16b4b31dfcbb3345ee01a @@ -469,6 +488,6 @@ SPEC CHECKSUMS: volume_controller: 531ddf792994285c9b17f9d8a7e4dcdd29b3eae9 wakelock_plus: 8b09852c8876491e4b6d179e17dfe2a0b5f60d47 -PODFILE CHECKSUM: c1a8f198a245ed1f10e40b617efdb129b021b225 +PODFILE CHECKSUM: 3c51755c77077d1aa4ef23d06c621f6e2569214c COCOAPODS: 1.15.2 diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 22d5e8e681..c2a5bfb377 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -307,6 +307,7 @@ "${BUILT_PRODUCTS_DIR}/fluttertoast/fluttertoast.framework", "${BUILT_PRODUCTS_DIR}/home_widget/home_widget.framework", "${BUILT_PRODUCTS_DIR}/image_editor_common/image_editor_common.framework", + "${BUILT_PRODUCTS_DIR}/image_picker_ios/image_picker_ios.framework", "${BUILT_PRODUCTS_DIR}/in_app_purchase_storekit/in_app_purchase_storekit.framework", "${BUILT_PRODUCTS_DIR}/integration_test/integration_test.framework", "${BUILT_PRODUCTS_DIR}/libwebp/libwebp.framework", @@ -337,6 +338,14 @@ "${BUILT_PRODUCTS_DIR}/video_thumbnail/video_thumbnail.framework", "${BUILT_PRODUCTS_DIR}/volume_controller/volume_controller.framework", "${BUILT_PRODUCTS_DIR}/wakelock_plus/wakelock_plus.framework", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/ffmpeg-kit-ios-min/ffmpegkit.framework/ffmpegkit", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/ffmpeg-kit-ios-min/libavcodec.framework/libavcodec", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/ffmpeg-kit-ios-min/libavdevice.framework/libavdevice", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/ffmpeg-kit-ios-min/libavfilter.framework/libavfilter", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/ffmpeg-kit-ios-min/libavformat.framework/libavformat", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/ffmpeg-kit-ios-min/libavutil.framework/libavutil", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/ffmpeg-kit-ios-min/libswresample.framework/libswresample", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/ffmpeg-kit-ios-min/libswscale.framework/libswscale", "${PODS_XCFRAMEWORKS_BUILD_DIR}/media_kit_libs_ios_video/Ass.framework/Ass", "${PODS_XCFRAMEWORKS_BUILD_DIR}/media_kit_libs_ios_video/Avcodec.framework/Avcodec", "${PODS_XCFRAMEWORKS_BUILD_DIR}/media_kit_libs_ios_video/Avfilter.framework/Avfilter", @@ -389,6 +398,7 @@ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/fluttertoast.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/home_widget.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/image_editor_common.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/image_picker_ios.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/in_app_purchase_storekit.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/integration_test.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libwebp.framework", @@ -419,6 +429,14 @@ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/video_thumbnail.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/volume_controller.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/wakelock_plus.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ffmpegkit.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libavcodec.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libavdevice.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libavfilter.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libavformat.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libavutil.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libswresample.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libswscale.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Ass.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Avcodec.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Avfilter.framework", diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-1024x1024@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-1024x1024@1x.png new file mode 100644 index 0000000000..e6bae73ea5 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-1024x1024@1x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-20x20@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-20x20@1x.png new file mode 100644 index 0000000000..bb28b6a74f Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-20x20@1x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-20x20@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-20x20@2x.png new file mode 100644 index 0000000000..edb8f95896 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-20x20@2x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-20x20@3x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-20x20@3x.png new file mode 100644 index 0000000000..a713469b6e Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-20x20@3x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-29x29@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-29x29@1x.png new file mode 100644 index 0000000000..cc98c8dbf8 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-29x29@1x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-29x29@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-29x29@2x.png new file mode 100644 index 0000000000..119692c5d1 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-29x29@2x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-29x29@3x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-29x29@3x.png new file mode 100644 index 0000000000..ed6d5a382b Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-29x29@3x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-40x40@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-40x40@1x.png new file mode 100644 index 0000000000..edb8f95896 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-40x40@1x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-40x40@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-40x40@2x.png new file mode 100644 index 0000000000..10e0242d59 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-40x40@2x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-40x40@3x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-40x40@3x.png new file mode 100644 index 0000000000..aea4e77fc3 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-40x40@3x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-50x50@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-50x50@1x.png new file mode 100644 index 0000000000..ef16e5d8d3 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-50x50@1x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-50x50@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-50x50@2x.png new file mode 100644 index 0000000000..65890b669d Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-50x50@2x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-57x57@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-57x57@1x.png new file mode 100644 index 0000000000..8ee523aabb Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-57x57@1x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-57x57@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-57x57@2x.png new file mode 100644 index 0000000000..2294b68ed0 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-57x57@2x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-60x60@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-60x60@2x.png new file mode 100644 index 0000000000..aea4e77fc3 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-60x60@2x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-60x60@3x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-60x60@3x.png new file mode 100644 index 0000000000..5d4fec318f Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-60x60@3x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-72x72@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-72x72@1x.png new file mode 100644 index 0000000000..730f69093a Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-72x72@1x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-72x72@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-72x72@2x.png new file mode 100644 index 0000000000..6d804c6329 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-72x72@2x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-76x76@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-76x76@1x.png new file mode 100644 index 0000000000..12bf9e7022 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-76x76@1x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-76x76@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-76x76@2x.png new file mode 100644 index 0000000000..fd483027e7 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-76x76@2x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-83.5x83.5@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-83.5x83.5@2x.png new file mode 100644 index 0000000000..ab2316f7d1 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-83.5x83.5@2x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Contents.json b/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Contents.json new file mode 100644 index 0000000000..624ea89d6b --- /dev/null +++ b/mobile/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Contents.json @@ -0,0 +1 @@ +{"images":[{"size":"20x20","idiom":"iphone","filename":"AppIcon-dev-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"AppIcon-dev-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"AppIcon-dev-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"AppIcon-dev-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"AppIcon-dev-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"AppIcon-dev-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"AppIcon-dev-40x40@3x.png","scale":"3x"},{"size":"50x50","idiom":"ipad","filename":"AppIcon-dev-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"AppIcon-dev-50x50@2x.png","scale":"2x"},{"size":"57x57","idiom":"iphone","filename":"AppIcon-dev-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"AppIcon-dev-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"AppIcon-dev-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"AppIcon-dev-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"AppIcon-dev-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"AppIcon-dev-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"AppIcon-dev-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"AppIcon-dev-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"AppIcon-dev-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"AppIcon-dev-40x40@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"AppIcon-dev-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"AppIcon-dev-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"AppIcon-dev-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"AppIcon-dev-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"AppIcon-dev-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"AppIcon-dev-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}} \ No newline at end of file diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index fe571afeb1..cebb2dcdbb 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -15,7 +15,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - ente Photos + Ente Photos CFBundlePackageType APPL CFBundleShortVersionString @@ -23,7 +23,7 @@ CFBundleSignature ???? MinimumOSVersion - 12.0 + 12.1 LSApplicationQueriesSchemes googlegmail diff --git a/mobile/lib/core/errors.dart b/mobile/lib/core/errors.dart index d39f6f027f..f1c7c24b3c 100644 --- a/mobile/lib/core/errors.dart +++ b/mobile/lib/core/errors.dart @@ -81,3 +81,7 @@ class SrpSetupNotCompleteError extends Error {} class SharingNotPermittedForFreeAccountsError extends Error {} class NoMediaLocationAccessError extends Error {} + +class PassKeySessionNotVerifiedError extends Error {} + +class PassKeySessionExpiredError extends Error {} diff --git a/mobile/lib/ente_theme_data.dart b/mobile/lib/ente_theme_data.dart index d00655437b..8e8ed61523 100644 --- a/mobile/lib/ente_theme_data.dart +++ b/mobile/lib/ente_theme_data.dart @@ -220,6 +220,18 @@ TextTheme _buildTextTheme(Color textColor) { } extension CustomColorScheme on ColorScheme { + Color get videoPlayerPrimaryColor => brightness == Brightness.light + ? const Color.fromRGBO(0, 179, 60, 1) + : const Color.fromRGBO(1, 222, 77, 1); + + Color get videoPlayerBackgroundColor => brightness == Brightness.light + ? const Color(0xFFF5F5F5) + : const Color(0xFF252525); + + Color get videoPlayerBorderColor => brightness == Brightness.light + ? const Color(0xFF424242) + : const Color(0xFFFFFFFF); + Color get defaultBackgroundColor => brightness == Brightness.light ? backgroundBaseLight : backgroundBaseDark; @@ -392,7 +404,9 @@ ElevatedButtonThemeData buildElevatedButtonThemeData({ }) { return ElevatedButtonThemeData( style: ElevatedButton.styleFrom( - foregroundColor: onPrimary, backgroundColor: primary, elevation: elevation, + foregroundColor: onPrimary, + backgroundColor: primary, + elevation: elevation, alignment: Alignment.center, textStyle: const TextStyle( fontWeight: FontWeight.w600, diff --git a/mobile/lib/face/db.dart b/mobile/lib/face/db.dart index abe4e19227..7ff180efed 100644 --- a/mobile/lib/face/db.dart +++ b/mobile/lib/face/db.dart @@ -841,6 +841,18 @@ class FaceMLDataDB { await db.executeBatch(sql, parameterSets); } + Future removeNotPersonFeedback({ + required String personID, + required int clusterID, + }) async { + final db = await instance.asyncDB; + + const String sql = ''' + DELETE FROM $notPersonFeedback WHERE $personIdColumn = ? AND $clusterIDColumn = ? + '''; + await db.execute(sql, [personID, clusterID]); + } + Future removeClusterToPerson({ required String personID, required int clusterID, diff --git a/mobile/lib/generated/intl/messages_de.dart b/mobile/lib/generated/intl/messages_de.dart index b3981075e6..1f9c043ed8 100644 --- a/mobile/lib/generated/intl/messages_de.dart +++ b/mobile/lib/generated/intl/messages_de.dart @@ -21,7 +21,7 @@ class MessageLookup extends MessageLookupByLibrary { String get localeName => 'de'; static String m0(count) => - "${Intl.plural(count, zero: 'Add collaborator', one: 'Add collaborator', other: 'Add collaborators')}"; + "${Intl.plural(count, one: 'Teilnehmer', other: 'Teilnehmer')} hinzufügen"; static String m2(count) => "${Intl.plural(count, one: 'Element hinzufügen', other: 'Elemente hinzufügen')}"; @@ -30,7 +30,7 @@ class MessageLookup extends MessageLookupByLibrary { "Dein ${storageAmount} Add-on ist gültig bis ${endDate}"; static String m1(count) => - "${Intl.plural(count, zero: 'Add viewer', one: 'Add viewer', other: 'Add viewers')}"; + "${Intl.plural(count, one: 'Betrachter', other: 'Betrachter')} hinzufügen"; static String m4(emailOrName) => "Von ${emailOrName} hinzugefügt"; @@ -41,13 +41,16 @@ class MessageLookup extends MessageLookupByLibrary { static String m7(versionValue) => "Version: ${versionValue}"; - static String m8(paymentProvider) => + static String m8(freeAmount, storageUnit) => + "${freeAmount} ${storageUnit} frei"; + + static String m9(paymentProvider) => "Bitte kündigen Sie Ihr aktuelles Abo über ${paymentProvider} zuerst"; - static String m9(user) => + static String m10(user) => "Der Nutzer \"${user}\" wird keine weiteren Fotos zum Album hinzufügen können.\n\nJedoch kann er weiterhin vorhandene Bilder, welche durch ihn hinzugefügt worden sind, wieder entfernen"; - static String m10(isFamilyMember, storageAmountInGb) => + static String m11(isFamilyMember, storageAmountInGb) => "${Intl.select(isFamilyMember, { 'true': 'Deine Familiengruppe hat bereits ${storageAmountInGb} GB erhalten', @@ -55,169 +58,167 @@ class MessageLookup extends MessageLookupByLibrary { 'other': 'Du hast bereits ${storageAmountInGb} GB erhalten!', })}"; - static String m11(albumName) => + static String m12(albumName) => "Kollaborativer Link für ${albumName} erstellt"; - static String m12(familyAdminEmail) => + static String m13(familyAdminEmail) => "Bitte kontaktiere ${familyAdminEmail} um dein Abo zu verwalten"; - static String m13(provider) => + static String m14(provider) => "Bitte kontaktieren Sie uns über support@ente.io, um Ihr ${provider} Abo zu verwalten."; - static String m14(count) => + static String m15(endpoint) => "Verbunden mit ${endpoint}"; + + static String m16(count) => "${Intl.plural(count, one: 'Lösche ${count} Element', other: 'Lösche ${count} Elemente')}"; - static String m15(currentlyDeleting, totalCount) => + static String m17(currentlyDeleting, totalCount) => "Lösche ${currentlyDeleting} / ${totalCount}"; - static String m16(albumName) => + static String m18(albumName) => "Der öffentliche Link zum Zugriff auf \"${albumName}\" wird entfernt."; - static String m17(supportEmail) => + static String m19(supportEmail) => "Bitte sende eine E-Mail an ${supportEmail} von deiner registrierten E-Mail-Adresse"; - static String m18(count, storageSaved) => + static String m20(count, storageSaved) => "Du hast ${Intl.plural(count, one: '${count} duplizierte Datei', other: '${count} dupliziere Dateien')} gelöscht und (${storageSaved}!) freigegeben"; - static String m19(count, formattedSize) => + static String m21(count, formattedSize) => "${count} Dateien, ${formattedSize} jede"; - static String m20(newEmail) => "E-Mail-Adresse geändert zu ${newEmail}"; + static String m22(newEmail) => "E-Mail-Adresse geändert zu ${newEmail}"; - static String m21(email) => - "${email} hat kein Ente-Konto.\n\nSenden Sie eine Einladung, um Fotos zu teilen."; + static String m23(email) => + "${email} hat kein Ente-Konto.\n\nSende eine Einladung, um Fotos zu teilen."; - static String m22(count, formattedNumber) => + static String m24(count, formattedNumber) => "${Intl.plural(count, one: '1 Datei', other: '${formattedNumber} Dateien')} auf diesem Gerät wurde(n) sicher gespeichert"; - static String m23(count, formattedNumber) => + static String m25(count, formattedNumber) => "${Intl.plural(count, one: '1 Datei', other: '${formattedNumber} Dateien')} in diesem Album wurde(n) sicher gespeichert"; - static String m24(storageAmountInGB) => + static String m26(storageAmountInGB) => "${storageAmountInGB} GB jedes Mal, wenn sich jemand mit deinem Code für einen bezahlten Tarif anmeldet"; - static String m25(freeAmount, storageUnit) => - "${freeAmount} ${storageUnit} kostenlos"; + static String m27(endDate) => "Kostenlose Demo verfügbar bis zum ${endDate}"; - static String m26(endDate) => "Kostenlose Demo verfügbar bis zum ${endDate}"; + static String m28(count) => + "Du kannst immernoch über Ente ${Intl.plural(count, one: 'darauf', other: 'auf sie')} zugreifen, solange du ein aktives Abo hast"; - static String m27(count) => - "Sie können immer noch ${Intl.plural(count, one: 'darauf', other: 'auf sie')} auf ente zugreifen, solange Sie ein aktives Abonnement haben"; + static String m29(sizeInMBorGB) => "${sizeInMBorGB} freigeben"; - static String m28(sizeInMBorGB) => "${sizeInMBorGB} freigeben"; - - static String m29(count, formattedSize) => + static String m30(count, formattedSize) => "${Intl.plural(count, one: 'Es kann vom Gerät gelöscht werden, um ${formattedSize} freizugeben', other: 'Sie können vom Gerät gelöscht werden, um ${formattedSize} freizugeben')}"; - static String m30(currentlyProcessing, totalCount) => + static String m31(currentlyProcessing, totalCount) => "Verarbeite ${currentlyProcessing} / ${totalCount}"; - static String m31(count) => + static String m32(count) => "${Intl.plural(count, one: '${count} Objekt', other: '${count} Objekte')}"; - static String m32(expiryTime) => "Link läuft am ${expiryTime} ab"; + static String m33(expiryTime) => "Link läuft am ${expiryTime} ab"; - static String m33(count, formattedCount) => + static String m34(count, formattedCount) => "${Intl.plural(count, zero: 'keine Erinnerungsstücke', one: '${formattedCount} Erinnerung', other: '${formattedCount} Erinnerungsstücke')}"; - static String m34(count) => + static String m35(count) => "${Intl.plural(count, one: 'Element verschieben', other: 'Elemente verschieben')}"; - static String m35(albumName) => "Erfolgreich zu ${albumName} hinzugefügt"; + static String m36(albumName) => "Erfolgreich zu ${albumName} hinzugefügt"; - static String m36(passwordStrengthValue) => + static String m37(passwordStrengthValue) => "Passwortstärke: ${passwordStrengthValue}"; - static String m37(providerName) => + static String m38(providerName) => "Bitte kontaktiere den Support von ${providerName}, falls etwas abgebucht wurde"; - static String m38(endDate) => + static String m39(endDate) => "Kostenlose Testversion gültig bis ${endDate}.\nSie können anschließend ein bezahltes Paket auswählen."; - static String m39(toEmail) => "Bitte sende uns eine E-Mail an ${toEmail}"; + static String m40(toEmail) => "Bitte sende uns eine E-Mail an ${toEmail}"; - static String m40(toEmail) => "Bitte sende die Protokolle an ${toEmail}"; + static String m41(toEmail) => "Bitte sende die Protokolle an ${toEmail}"; - static String m41(storeName) => "Bewerte uns auf ${storeName}"; + static String m42(storeName) => "Bewerte uns auf ${storeName}"; - static String m42(storageInGB) => + static String m43(storageInGB) => "3. Ihr beide erhaltet ${storageInGB} GB* kostenlos"; - static String m43(userEmail) => + static String m44(userEmail) => "${userEmail} wird aus diesem geteilten Album entfernt\n\nAlle von ihnen hinzugefügte Fotos werden ebenfalls aus dem Album entfernt"; - static String m44(endDate) => "Erneuert am ${endDate}"; + static String m45(endDate) => "Erneuert am ${endDate}"; - static String m45(count) => + static String m46(count) => "${Intl.plural(count, one: '${count} Ergebnis gefunden', other: '${count} Ergebnisse gefunden')}"; - static String m46(count) => "${count} ausgewählt"; + static String m47(count) => "${count} ausgewählt"; - static String m47(count, yourCount) => + static String m48(count, yourCount) => "${count} ausgewählt (${yourCount} von Ihnen)"; - static String m48(verificationID) => + static String m49(verificationID) => "Hier ist meine Verifizierungs-ID: ${verificationID} für ente.io."; - static String m49(verificationID) => + static String m50(verificationID) => "Hey, kannst du bestätigen, dass dies deine ente.io Verifizierungs-ID ist: ${verificationID}"; - static String m50(referralCode, referralStorageInGB) => - "ente Weiterempfehlungs-Code: ${referralCode} \n\nEinlösen unter Einstellungen → Allgemein → Weiterempfehlungen, um ${referralStorageInGB} GB kostenlos zu erhalten, sobald Sie einen kostenpflichtigen Tarif abgeschlossen haben\n\nhttps://ente.io"; + static String m51(referralCode, referralStorageInGB) => + "Ente Weiterempfehlungs-Code: ${referralCode} \n\nEinlösen unter Einstellungen → Allgemein → Weiterempfehlungen, um ${referralStorageInGB} GB kostenlos zu erhalten, sobald Sie einen kostenpflichtigen Tarif abgeschlossen haben\n\nhttps://ente.io"; - static String m51(numberOfPeople) => + static String m52(numberOfPeople) => "${Intl.plural(numberOfPeople, zero: 'Teile mit bestimmten Personen', one: 'Teilen mit 1 Person', other: 'Teilen mit ${numberOfPeople} Personen')}"; - static String m52(emailIDs) => "Geteilt mit ${emailIDs}"; - - static String m53(fileType) => - "Dieses ${fileType} wird von deinem Gerät gelöscht."; + static String m53(emailIDs) => "Geteilt mit ${emailIDs}"; static String m54(fileType) => - "Dieses ${fileType} existiert auf ente.io und deinem Gerät."; + "Dieses ${fileType} wird von deinem Gerät gelöscht."; static String m55(fileType) => - "Dieses ${fileType} wird auf ente.io gelöscht."; + "Diese Datei ist sowohl in Ente als auch auf deinem Gerät."; - static String m56(storageAmountInGB) => "${storageAmountInGB} GB"; + static String m56(fileType) => "Diese Datei wird von Ente gelöscht."; - static String m57( + static String m57(storageAmountInGB) => "${storageAmountInGB} GB"; + + static String m58( usedAmount, usedStorageUnit, totalAmount, totalStorageUnit) => "${usedAmount} ${usedStorageUnit} von ${totalAmount} ${totalStorageUnit} verwendet"; - static String m58(id) => - "Ihr ${id} ist bereits mit einem anderen \'ente\'-Konto verknüpft.\nWenn Sie Ihre ${id} mit diesem Konto verwenden möchten, kontaktieren Sie bitte unseren Support\'"; + static String m59(id) => + "Ihr ${id} ist bereits mit einem anderen Ente-Konto verknüpft.\nWenn Sie Ihre ${id} mit diesem Konto verwenden möchten, kontaktieren Sie bitte unseren Support"; - static String m59(endDate) => "Ihr Abo endet am ${endDate}"; + static String m60(endDate) => "Ihr Abo endet am ${endDate}"; - static String m60(completed, total) => + static String m61(completed, total) => "${completed}/${total} Erinnerungsstücke gesichert"; - static String m61(storageAmountInGB) => + static String m62(storageAmountInGB) => "Diese erhalten auch ${storageAmountInGB} GB"; - static String m62(email) => "Dies ist ${email}s Verifizierungs-ID"; + static String m63(email) => "Dies ist ${email}s Verifizierungs-ID"; - static String m63(count) => + static String m64(count) => "${Intl.plural(count, zero: '', one: '1 Tag', other: '${count} Tage')}"; - static String m64(endDate) => "Gültig bis ${endDate}"; + static String m65(endDate) => "Gültig bis ${endDate}"; - static String m65(email) => "Verifiziere ${email}"; + static String m66(email) => "Verifiziere ${email}"; - static String m66(email) => + static String m67(email) => "Wir haben eine E-Mail an ${email} gesendet"; - static String m67(count) => + static String m68(count) => "${Intl.plural(count, one: 'vor einem Jahr', other: 'vor ${count} Jahren')}"; - static String m68(storageSaved) => + static String m69(storageSaved) => "Du hast ${storageSaved} erfolgreich freigegeben!"; final messages = _notInlinedMessages(_notInlinedMessages); static Map _notInlinedMessages(_) => { "aNewVersionOfEnteIsAvailable": MessageLookupByLibrary.simpleMessage( - "Eine neuere Version von \'ente\' ist verfügbar."), + "Eine neue Version von Ente ist verfügbar."), "about": MessageLookupByLibrary.simpleMessage("Allgemeine Informationen"), "account": MessageLookupByLibrary.simpleMessage("Konto"), @@ -227,7 +228,8 @@ class MessageLookup extends MessageLookupByLibrary { "Ich verstehe, dass ich meine Daten verlieren kann, wenn ich mein Passwort vergesse, da meine Daten Ende-zu-Ende-verschlüsselt sind."), "activeSessions": MessageLookupByLibrary.simpleMessage("Aktive Sitzungen"), - "addAName": MessageLookupByLibrary.simpleMessage("Add a name"), + "addAName": + MessageLookupByLibrary.simpleMessage("Füge einen Namen hinzu"), "addANewEmail": MessageLookupByLibrary.simpleMessage( "Neue E-Mail-Adresse hinzufügen"), "addCollaborator": @@ -249,7 +251,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Auswahl hinzufügen"), "addToAlbum": MessageLookupByLibrary.simpleMessage("Zum Album hinzufügen"), - "addToEnte": MessageLookupByLibrary.simpleMessage("Zu ente hinzufügen"), + "addToEnte": MessageLookupByLibrary.simpleMessage("Zu Ente hinzufügen"), "addToHiddenAlbum": MessageLookupByLibrary.simpleMessage( "Zum versteckten Album hinzufügen"), "addViewer": MessageLookupByLibrary.simpleMessage("Album teilen"), @@ -367,7 +369,18 @@ class MessageLookup extends MessageLookupByLibrary { "Authentifizierung fehlgeschlagen, versuchen Sie es bitte erneut"), "authenticationSuccessful": MessageLookupByLibrary.simpleMessage( "Authentifizierung erfogreich!"), + "autoCastDialogBody": MessageLookupByLibrary.simpleMessage( + "Verfügbare Cast-Geräte werden hier angezeigt."), + "autoCastiOSPermission": MessageLookupByLibrary.simpleMessage( + "Stelle sicher, dass die Ente-App auf das lokale Netzwerk zugreifen darf. Das kannst du in den Einstellungen unter \"Datenschutz\"."), + "autoLogoutMessage": MessageLookupByLibrary.simpleMessage( + "Aufgrund technischer Störungen wurden Sie abgemeldet. Wir entschuldigen uns für die Unannehmlichkeiten."), + "autoPair": + MessageLookupByLibrary.simpleMessage("Automatisch verbinden"), + "autoPairDesc": MessageLookupByLibrary.simpleMessage( + "Automatisches Verbinden funktioniert nur mit Geräten, die Chromecast unterstützen."), "available": MessageLookupByLibrary.simpleMessage("Verfügbar"), + "availableStorageSpace": m8, "backedUpFolders": MessageLookupByLibrary.simpleMessage("Gesicherte Ordner"), "backup": MessageLookupByLibrary.simpleMessage("Backup"), @@ -393,12 +406,16 @@ class MessageLookup extends MessageLookupByLibrary { "canOnlyRemoveFilesOwnedByYou": MessageLookupByLibrary.simpleMessage( "Du kannst nur Dateien entfernen, die dir gehören"), "cancel": MessageLookupByLibrary.simpleMessage("Abbrechen"), - "cancelOtherSubscription": m8, + "cancelOtherSubscription": m9, "cancelSubscription": MessageLookupByLibrary.simpleMessage("Abonnement kündigen"), - "cannotAddMorePhotosAfterBecomingViewer": m9, + "cannotAddMorePhotosAfterBecomingViewer": m10, "cannotDeleteSharedFiles": MessageLookupByLibrary.simpleMessage( "Konnte geteilte Dateien nicht löschen"), + "castIPMismatchBody": MessageLookupByLibrary.simpleMessage( + "Stelle sicher, dass du im selben Netzwerk bist wie der Fernseher."), + "castIPMismatchTitle": MessageLookupByLibrary.simpleMessage( + "Album konnte nicht auf den Bildschirm übertragen werden"), "castInstruction": MessageLookupByLibrary.simpleMessage( "Besuche cast.ente.io auf dem Gerät, das du verbinden möchtest.\n\nGib den unten angegebenen Code ein, um das Album auf deinem Fernseher abzuspielen."), "centerPoint": MessageLookupByLibrary.simpleMessage("Mittelpunkt"), @@ -421,7 +438,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Freien Speicher einlösen"), "claimMore": MessageLookupByLibrary.simpleMessage("Mehr einlösen!"), "claimed": MessageLookupByLibrary.simpleMessage("Eingelöst"), - "claimedStorageSoFar": m10, + "claimedStorageSoFar": m11, "cleanUncategorized": MessageLookupByLibrary.simpleMessage("Unkategorisiert leeren"), "cleanUncategorizedDescription": MessageLookupByLibrary.simpleMessage( @@ -437,7 +454,7 @@ class MessageLookup extends MessageLookupByLibrary { "clubByFileName": MessageLookupByLibrary.simpleMessage("Nach Dateiname gruppieren"), "clusteringProgress": - MessageLookupByLibrary.simpleMessage("Clustering progress"), + MessageLookupByLibrary.simpleMessage("Fortschritt beim Clustering"), "codeAppliedPageTitle": MessageLookupByLibrary.simpleMessage("Code eingelöst"), "codeCopiedToClipboard": MessageLookupByLibrary.simpleMessage( @@ -445,10 +462,10 @@ class MessageLookup extends MessageLookupByLibrary { "codeUsedByYou": MessageLookupByLibrary.simpleMessage("Von dir benutzter Code"), "collabLinkSectionDescription": MessageLookupByLibrary.simpleMessage( - "Erstelle einen Link, um anderen zu ermöglichen, Fotos in deinem geteilten Album hinzuzufügen und zu sehen - ohne dass diese ein Konto von ente.io oder die App benötigen. Ideal, um Fotos von Events zu sammeln."), + "Erstelle einen Link, mit dem andere Fotos in dem geteilten Album sehen und selbst welche hinzufügen können - ohne dass sie die ein Ente-Konto oder die App benötigen. Ideal um gemeinsam Fotos von Events zu sammeln."), "collaborativeLink": MessageLookupByLibrary.simpleMessage("Gemeinschaftlicher Link"), - "collaborativeLinkCreatedFor": m11, + "collaborativeLinkCreatedFor": m12, "collaborator": MessageLookupByLibrary.simpleMessage("Bearbeiter"), "collaboratorsCanAddPhotosAndVideosToTheSharedAlbum": MessageLookupByLibrary.simpleMessage( @@ -475,10 +492,12 @@ class MessageLookup extends MessageLookupByLibrary { "Wiederherstellungsschlüssel bestätigen"), "confirmYourRecoveryKey": MessageLookupByLibrary.simpleMessage( "Bestätigen Sie ihren Wiederherstellungsschlüssel"), - "contactFamilyAdmin": m12, + "connectToDevice": + MessageLookupByLibrary.simpleMessage("Mit Gerät verbinden"), + "contactFamilyAdmin": m13, "contactSupport": MessageLookupByLibrary.simpleMessage("Support kontaktieren"), - "contactToManageSubscription": m13, + "contactToManageSubscription": m14, "contacts": MessageLookupByLibrary.simpleMessage("Kontakte"), "contents": MessageLookupByLibrary.simpleMessage("Inhalte"), "continueLabel": MessageLookupByLibrary.simpleMessage("Weiter"), @@ -506,8 +525,8 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Konto erstellen"), "createAlbumActionHint": MessageLookupByLibrary.simpleMessage( "Drücke lange um Fotos auszuwählen und klicke + um ein Album zu erstellen"), - "createCollaborativeLink": - MessageLookupByLibrary.simpleMessage("Create collaborative link"), + "createCollaborativeLink": MessageLookupByLibrary.simpleMessage( + "Gemeinschaftlichen Link erstellen"), "createCollage": MessageLookupByLibrary.simpleMessage("Collage erstellen"), "createNewAccount": @@ -523,6 +542,7 @@ class MessageLookup extends MessageLookupByLibrary { "currentUsageIs": MessageLookupByLibrary.simpleMessage("Aktuell genutzt werden "), "custom": MessageLookupByLibrary.simpleMessage("Benutzerdefiniert"), + "customEndpoint": m15, "darkTheme": MessageLookupByLibrary.simpleMessage("Dunkel"), "dayToday": MessageLookupByLibrary.simpleMessage("Heute"), "dayYesterday": MessageLookupByLibrary.simpleMessage("Gestern"), @@ -545,7 +565,7 @@ class MessageLookup extends MessageLookupByLibrary { "Damit werden alle leeren Alben gelöscht. Dies ist nützlich, wenn du das Durcheinander in deiner Albenliste verringern möchtest."), "deleteAll": MessageLookupByLibrary.simpleMessage("Alle löschen"), "deleteConfirmDialogBody": MessageLookupByLibrary.simpleMessage( - "Dieses Konto ist mit anderen ente Apps verknüpft, sofern du diese benutzt.\\n\\nDeine hochgeladenen Daten werden zur permanenten Löschung freigegeben. Dies gilt für alle ente Apps."), + "Dieses Konto ist mit anderen Ente-Apps verknüpft, falls du welche verwendest. Deine hochgeladenen Daten werden in allen Ente-Apps zur Löschung vorgemerkt und dein Konto wird endgültig gelöscht."), "deleteEmailRequest": MessageLookupByLibrary.simpleMessage( "Bitte sende eine E-Mail an account-deletion@ente.io von Deiner bei uns hinterlegten E-Mail-Adresse."), "deleteEmptyAlbums": @@ -557,12 +577,12 @@ class MessageLookup extends MessageLookupByLibrary { "deleteFromDevice": MessageLookupByLibrary.simpleMessage("Vom Gerät löschen"), "deleteFromEnte": - MessageLookupByLibrary.simpleMessage("Auf ente.io löschen"), - "deleteItemCount": m14, + MessageLookupByLibrary.simpleMessage("Von Ente löschen"), + "deleteItemCount": m16, "deleteLocation": MessageLookupByLibrary.simpleMessage("Standort löschen"), "deletePhotos": MessageLookupByLibrary.simpleMessage("Fotos löschen"), - "deleteProgress": m15, + "deleteProgress": m17, "deleteReason1": MessageLookupByLibrary.simpleMessage( "Es fehlt eine zentrale Funktion, die ich benötige"), "deleteReason2": MessageLookupByLibrary.simpleMessage( @@ -582,11 +602,15 @@ class MessageLookup extends MessageLookupByLibrary { "designedToOutlive": MessageLookupByLibrary.simpleMessage("Entwickelt um zu bewahren"), "details": MessageLookupByLibrary.simpleMessage("Details"), + "developerSettings": + MessageLookupByLibrary.simpleMessage("Entwicklereinstellungen"), + "developerSettingsWarning": MessageLookupByLibrary.simpleMessage( + "Bist du sicher, dass du Entwicklereinstellungen bearbeiten willst?"), "deviceCodeHint": MessageLookupByLibrary.simpleMessage("Code eingeben"), "deviceFilesAutoUploading": MessageLookupByLibrary.simpleMessage( - "Dateien, die zu diesem Album hinzugefügt werden, werden automatisch zu ente hochgeladen."), + "Dateien, die zu diesem Album hinzugefügt werden, werden automatisch zu Ente hochgeladen."), "deviceLockExplanation": MessageLookupByLibrary.simpleMessage( - "Das Sperren des Gerätes verhindern, solange \'ente\' im Vordergrund geöffnet ist und eine Sicherung läuft. \nDies wird für gewöhnlich nicht benötigt, kann aber dabei helfen große Transfers schneller durchzuführen."), + "Verhindern, dass der Bildschirm gesperrt wird, während die App im Vordergrund ist und eine Sicherung läuft. Das ist normalerweise nicht notwendig, kann aber dabei helfen, große Uploads wie einen Erstimport schneller abzuschließen."), "deviceNotFound": MessageLookupByLibrary.simpleMessage("Gerät nicht gefunden"), "didYouKnow": MessageLookupByLibrary.simpleMessage("Schon gewusst?"), @@ -596,7 +620,7 @@ class MessageLookup extends MessageLookupByLibrary { "Zuschauer können weiterhin Screenshots oder mit anderen externen Programmen Kopien der Bilder machen."), "disableDownloadWarningTitle": MessageLookupByLibrary.simpleMessage("Bitte beachten Sie:"), - "disableLinkMessage": m16, + "disableLinkMessage": m18, "disableTwofactor": MessageLookupByLibrary.simpleMessage( "Zweiten Faktor (2FA) deaktivieren"), "disablingTwofactorAuthentication": @@ -619,9 +643,9 @@ class MessageLookup extends MessageLookupByLibrary { "Herunterladen fehlgeschlagen"), "downloading": MessageLookupByLibrary.simpleMessage("Wird heruntergeladen..."), - "dropSupportEmail": m17, - "duplicateFileCountWithStorageSaved": m18, - "duplicateItemsGroup": m19, + "dropSupportEmail": m19, + "duplicateFileCountWithStorageSaved": m20, + "duplicateItemsGroup": m21, "edit": MessageLookupByLibrary.simpleMessage("Bearbeiten"), "editLocation": MessageLookupByLibrary.simpleMessage("Standort bearbeiten"), @@ -631,11 +655,11 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Änderungen gespeichert"), "editsToLocationWillOnlyBeSeenWithinEnte": MessageLookupByLibrary.simpleMessage( - "Änderungen des Standorts werden nur in ente sichtbar sein"), + "Edits to location will only be seen within Ente"), "eligible": MessageLookupByLibrary.simpleMessage("zulässig"), "email": MessageLookupByLibrary.simpleMessage("E-Mail"), - "emailChangedTo": m20, - "emailNoEnteAccount": m21, + "emailChangedTo": m22, + "emailNoEnteAccount": m23, "emailVerificationToggle": MessageLookupByLibrary.simpleMessage("E-Mail-Verifizierung"), "emailYourLogs": MessageLookupByLibrary.simpleMessage( @@ -651,15 +675,16 @@ class MessageLookup extends MessageLookupByLibrary { "encryption": MessageLookupByLibrary.simpleMessage("Verschlüsselung"), "encryptionKeys": MessageLookupByLibrary.simpleMessage("Verschlüsselungscode"), + "endpointUpdatedMessage": MessageLookupByLibrary.simpleMessage( + "Endpunkt erfolgreich geändert"), "endtoendEncryptedByDefault": MessageLookupByLibrary.simpleMessage( "Automatisch Ende-zu-Ende-verschlüsselt"), "enteCanEncryptAndPreserveFilesOnlyIfYouGrant": MessageLookupByLibrary.simpleMessage( - "ente kann Dateien nur verschlüsselt sichern, wenn du uns darauf Zugriff gewährst"), - "entePhotosPerm": MessageLookupByLibrary.simpleMessage( - "ente benötigt die Erlaubnis, deine Fotos aufzubewahren"), + "Ente kann Dateien nur verschlüsseln und sichern, wenn du den Zugriff darauf gewährst"), + "entePhotosPerm": MessageLookupByLibrary.simpleMessage(""), "enteSubscriptionPitch": MessageLookupByLibrary.simpleMessage( - "ente sichert deine Erinnerungsstücke, sodass sie immer für dich verfügbar sind, auch wenn du dein Gerät verlieren solltest."), + "Ente sichert deine Erinnerungen, sodass sie dir nie verloren gehen, selbst wenn du dein Gerät verlierst."), "enteSubscriptionShareWithFamily": MessageLookupByLibrary.simpleMessage( "Deine Familie kann zu deinem Abo hinzugefügt werden."), "enterAlbumName": @@ -677,7 +702,7 @@ class MessageLookup extends MessageLookupByLibrary { "enterPasswordToEncrypt": MessageLookupByLibrary.simpleMessage( "Gib ein Passwort ein, mit dem wir deine Daten verschlüsseln können"), "enterPersonName": - MessageLookupByLibrary.simpleMessage("Enter person name"), + MessageLookupByLibrary.simpleMessage("Namen der Person eingeben"), "enterReferralCode": MessageLookupByLibrary.simpleMessage( "Gib den Weiterempfehlungs-Code ein"), "enterThe6digitCodeFromnyourAuthenticatorApp": @@ -703,7 +728,7 @@ class MessageLookup extends MessageLookupByLibrary { "exportYourData": MessageLookupByLibrary.simpleMessage("Daten exportieren"), "faceRecognition": - MessageLookupByLibrary.simpleMessage("Face recognition"), + MessageLookupByLibrary.simpleMessage("Gesichtserkennung"), "faces": MessageLookupByLibrary.simpleMessage("Gesichter"), "failedToApplyCode": MessageLookupByLibrary.simpleMessage( "Der Code konnte nicht aktiviert werden"), @@ -722,7 +747,7 @@ class MessageLookup extends MessageLookupByLibrary { "failedToVerifyPaymentStatus": MessageLookupByLibrary.simpleMessage( "Überprüfung des Zahlungsstatus fehlgeschlagen"), "familyPlanOverview": MessageLookupByLibrary.simpleMessage( - "Fügen Sie 5 Familienmitglieder zu Ihrem bestehenden Abo hinzu, ohne extra zu bezahlen.\n\nJedes Mitglied erhält einen eigenen privaten Raum und kann die Dateien von anderen nicht sehen, wenn sie nicht freigegeben werden.\n\nFamilien-Abos sind für Kunden verfügbar, die ein kostenpflichtiges ente Abonnement haben.\n\nMelden Sie sich jetzt an, um loszulegen!"), + "Füge kostenlos 5 Familienmitglieder zu deinem bestehenden Abo hinzu.\n\nJedes Mitglied bekommt seinen eigenen privaten Bereich und kann die Dateien der anderen nur sehen, wenn sie geteilt werden.\n\nFamilien-Abos stehen Nutzern mit einem Bezahltarif zur Verfügung.\n\nMelde dich jetzt an, um loszulegen!"), "familyPlanPortalTitle": MessageLookupByLibrary.simpleMessage("Familie"), "familyPlans": MessageLookupByLibrary.simpleMessage("Familientarif"), @@ -739,40 +764,42 @@ class MessageLookup extends MessageLookupByLibrary { "fileTypes": MessageLookupByLibrary.simpleMessage("Dateitypen"), "fileTypesAndNames": MessageLookupByLibrary.simpleMessage("Dateitypen und -namen"), - "filesBackedUpFromDevice": m22, - "filesBackedUpInAlbum": m23, + "filesBackedUpFromDevice": m24, + "filesBackedUpInAlbum": m25, "filesDeleted": MessageLookupByLibrary.simpleMessage("Dateien gelöscht"), + "filesSavedToGallery": MessageLookupByLibrary.simpleMessage( + "Dateien in Galerie gespeichert"), "findPeopleByName": MessageLookupByLibrary.simpleMessage( - "Find people quickly by searching by name"), + "Finde Personen schnell nach Namen"), "flip": MessageLookupByLibrary.simpleMessage("Spiegeln"), "forYourMemories": MessageLookupByLibrary.simpleMessage("Als Erinnerung"), "forgotPassword": MessageLookupByLibrary.simpleMessage("Passwort vergessen"), - "foundFaces": MessageLookupByLibrary.simpleMessage("Found faces"), + "foundFaces": + MessageLookupByLibrary.simpleMessage("Gesichter gefunden"), "freeStorageClaimed": MessageLookupByLibrary.simpleMessage( "Kostenlos hinzugefügter Speicherplatz"), - "freeStorageOnReferralSuccess": m24, - "freeStorageSpace": m25, + "freeStorageOnReferralSuccess": m26, "freeStorageUsable": MessageLookupByLibrary.simpleMessage( "Freier Speicherplatz nutzbar"), "freeTrial": MessageLookupByLibrary.simpleMessage("Kostenlose Testphase"), - "freeTrialValidTill": m26, - "freeUpAccessPostDelete": m27, - "freeUpAmount": m28, + "freeTrialValidTill": m27, + "freeUpAccessPostDelete": m28, + "freeUpAmount": m29, "freeUpDeviceSpace": MessageLookupByLibrary.simpleMessage("Gerätespeicher freiräumen"), "freeUpSpace": MessageLookupByLibrary.simpleMessage("Speicherplatz freigeben"), - "freeUpSpaceSaving": m29, + "freeUpSpaceSaving": m30, "galleryMemoryLimitInfo": MessageLookupByLibrary.simpleMessage( "Bis zu 1000 Erinnerungsstücke angezeigt in der Galerie"), "general": MessageLookupByLibrary.simpleMessage("Allgemein"), "generatingEncryptionKeys": MessageLookupByLibrary.simpleMessage( "Generierung von Verschlüsselungscodes..."), - "genericProgress": m30, + "genericProgress": m31, "goToSettings": MessageLookupByLibrary.simpleMessage("Zu den Einstellungen"), "googlePlayId": MessageLookupByLibrary.simpleMessage("Google Play ID"), @@ -786,6 +813,7 @@ class MessageLookup extends MessageLookupByLibrary { "Wir tracken keine App-Installationen. Es würde uns jedoch helfen, wenn du uns mitteilst, wie du von uns erfahren hast!"), "hearUsWhereTitle": MessageLookupByLibrary.simpleMessage( "Wie hast du von Ente erfahren? (optional)"), + "help": MessageLookupByLibrary.simpleMessage("Hilfe"), "hidden": MessageLookupByLibrary.simpleMessage("Versteckt"), "hide": MessageLookupByLibrary.simpleMessage("Ausblenden"), "hiding": MessageLookupByLibrary.simpleMessage("Verstecken..."), @@ -802,7 +830,7 @@ class MessageLookup extends MessageLookupByLibrary { "iOSOkButton": MessageLookupByLibrary.simpleMessage("OK"), "ignoreUpdate": MessageLookupByLibrary.simpleMessage("Ignorieren"), "ignoredFolderUploadReason": MessageLookupByLibrary.simpleMessage( - "Einige Dateien in diesem Album werden beim Upload ignoriert, weil sie zuvor auf ente gelöscht wurden."), + "Ein paar Dateien in diesem Album werden nicht hochgeladen, weil sie in der Vergangenheit schonmal aus Ente gelöscht wurden."), "importing": MessageLookupByLibrary.simpleMessage("Importiert...."), "incorrectCode": MessageLookupByLibrary.simpleMessage("Falscher Code"), "incorrectPasswordTitle": @@ -816,28 +844,32 @@ class MessageLookup extends MessageLookupByLibrary { "indexedItems": MessageLookupByLibrary.simpleMessage("Indizierte Elemente"), "indexingIsPaused": MessageLookupByLibrary.simpleMessage( - "Indexing is paused, will automatically resume when device is ready"), + "Die Indizierung ist unterbrochen. Sie wird automatisch fortgesetzt, wenn das Gerät bereit ist."), "insecureDevice": MessageLookupByLibrary.simpleMessage("Unsicheres Gerät"), "installManually": MessageLookupByLibrary.simpleMessage("Manuell installieren"), "invalidEmailAddress": MessageLookupByLibrary.simpleMessage("Ungültige E-Mail-Adresse"), + "invalidEndpoint": + MessageLookupByLibrary.simpleMessage("Ungültiger Endpunkt"), + "invalidEndpointMessage": MessageLookupByLibrary.simpleMessage( + "Der eingegebene Endpunkt ist ungültig. Gib einen gültigen Endpunkt ein und versuch es nochmal."), "invalidKey": MessageLookupByLibrary.simpleMessage("Ungültiger Schlüssel"), "invalidRecoveryKey": MessageLookupByLibrary.simpleMessage( "Der von Ihnen eingegebene Wiederherstellungsschlüssel ist nicht gültig. Bitte stellen Sie sicher das aus 24 Wörtern zusammen gesetzt ist und jedes dieser Worte richtig geschrieben wurde.\n\nSollten Sie den Wiederherstellungscode eingegeben haben, stellen Sie bitte sicher, dass dieser 64 Worte lang ist und ebenfall richtig geschrieben wurde."), "invite": MessageLookupByLibrary.simpleMessage("Einladen"), "inviteToEnte": - MessageLookupByLibrary.simpleMessage("Zu ente einladen"), + MessageLookupByLibrary.simpleMessage("Zu Ente einladen"), "inviteYourFriends": MessageLookupByLibrary.simpleMessage("Lade deine Freunde ein"), "inviteYourFriendsToEnte": MessageLookupByLibrary.simpleMessage( - "Lade deine Freunde zu ente ein"), + "Lade deine Freunde zu Ente ein"), "itLooksLikeSomethingWentWrongPleaseRetryAfterSome": MessageLookupByLibrary.simpleMessage( "Etwas ist schiefgelaufen. Bitte versuche es später noch einmal. Sollte der Fehler weiter bestehen, kontaktiere unser Supportteam."), - "itemCount": m31, + "itemCount": m32, "itemsShowTheNumberOfDaysRemainingBeforePermanentDeletion": MessageLookupByLibrary.simpleMessage( "Elemente zeigen die Anzahl der Tage bis zum dauerhaften Löschen an"), @@ -865,7 +897,7 @@ class MessageLookup extends MessageLookupByLibrary { "linkDeviceLimit": MessageLookupByLibrary.simpleMessage("Geräte Limit"), "linkEnabled": MessageLookupByLibrary.simpleMessage("Aktiviert"), "linkExpired": MessageLookupByLibrary.simpleMessage("Abgelaufen"), - "linkExpiresOn": m32, + "linkExpiresOn": m33, "linkExpiry": MessageLookupByLibrary.simpleMessage("Ablaufdatum des Links"), "linkHasExpired": @@ -917,7 +949,7 @@ class MessageLookup extends MessageLookupByLibrary { "Dies wird über Logs gesendet, um uns zu helfen, Ihr Problem zu beheben. Bitte beachten Sie, dass Dateinamen aufgenommen werden, um Probleme mit bestimmten Dateien zu beheben."), "longPressAnEmailToVerifyEndToEndEncryption": MessageLookupByLibrary.simpleMessage( - "Long press an email to verify end to end encryption."), + "Lange auf eine E-Mail drücken, um die Ende-zu-Ende-Verschlüsselung zu überprüfen."), "longpressOnAnItemToViewInFullscreen": MessageLookupByLibrary.simpleMessage( "Drücken Sie lange auf ein Element, um es im Vollbildmodus anzuzeigen"), "lostDevice": MessageLookupByLibrary.simpleMessage("Gerät verloren?"), @@ -933,12 +965,16 @@ class MessageLookup extends MessageLookupByLibrary { "manageParticipants": MessageLookupByLibrary.simpleMessage("Verwalten"), "manageSubscription": MessageLookupByLibrary.simpleMessage("Abonnement verwalten"), + "manualPairDesc": MessageLookupByLibrary.simpleMessage( + "\"Mit PIN verbinden\" funktioniert mit jedem Bildschirm, auf dem du dein Album sehen möchtest."), "map": MessageLookupByLibrary.simpleMessage("Karte"), "maps": MessageLookupByLibrary.simpleMessage("Karten"), "mastodon": MessageLookupByLibrary.simpleMessage("Mastodon"), "matrix": MessageLookupByLibrary.simpleMessage("Matrix"), - "memoryCount": m33, + "memoryCount": m34, "merchandise": MessageLookupByLibrary.simpleMessage("Merchandise"), + "mlIndexingDescription": MessageLookupByLibrary.simpleMessage( + "Bitte beachten Sie, dass Machine Learning zu einem höheren Bandbreiten- und Batterieverbrauch führt, bis alle Elemente indiziert sind."), "mobileWebDesktop": MessageLookupByLibrary.simpleMessage("Mobil, Web, Desktop"), "moderateStrength": MessageLookupByLibrary.simpleMessage("Mittel"), @@ -947,12 +983,12 @@ class MessageLookup extends MessageLookupByLibrary { "Ändere deine Suchanfrage oder suche nach"), "moments": MessageLookupByLibrary.simpleMessage("Momente"), "monthly": MessageLookupByLibrary.simpleMessage("Monatlich"), - "moveItem": m34, + "moveItem": m35, "moveToAlbum": MessageLookupByLibrary.simpleMessage("Zum Album verschieben"), "moveToHiddenAlbum": MessageLookupByLibrary.simpleMessage( "Zu verstecktem Album verschieben"), - "movedSuccessfullyTo": m35, + "movedSuccessfullyTo": m36, "movedToTrash": MessageLookupByLibrary.simpleMessage( "In den Papierkorb verschoben"), "movingFilesToAlbum": MessageLookupByLibrary.simpleMessage( @@ -964,11 +1000,13 @@ class MessageLookup extends MessageLookupByLibrary { "Ente ist im Moment nicht erreichbar. Bitte überprüfen Sie Ihre Netzwerkeinstellungen. Sollte das Problem bestehen bleiben, wenden Sie sich bitte an den Support."), "never": MessageLookupByLibrary.simpleMessage("Niemals"), "newAlbum": MessageLookupByLibrary.simpleMessage("Neues Album"), - "newToEnte": MessageLookupByLibrary.simpleMessage("Neu bei ente"), + "newToEnte": MessageLookupByLibrary.simpleMessage("Neu bei Ente"), "newest": MessageLookupByLibrary.simpleMessage("Zuletzt"), "no": MessageLookupByLibrary.simpleMessage("Nein"), "noAlbumsSharedByYouYet": MessageLookupByLibrary.simpleMessage( "Noch keine Alben von dir geteilt"), + "noDeviceFound": + MessageLookupByLibrary.simpleMessage("Kein Gerät gefunden"), "noDeviceLimit": MessageLookupByLibrary.simpleMessage("Keins"), "noDeviceThatCanBeDeleted": MessageLookupByLibrary.simpleMessage( "Du hast keine Dateien auf diesem Gerät, die gelöscht werden können"), @@ -1018,6 +1056,9 @@ class MessageLookup extends MessageLookupByLibrary { "orPickAnExistingOne": MessageLookupByLibrary.simpleMessage( "Oder eine Vorherige auswählen"), "pair": MessageLookupByLibrary.simpleMessage("Koppeln"), + "pairWithPin": + MessageLookupByLibrary.simpleMessage("Mit PIN verbinden"), + "pairingComplete": MessageLookupByLibrary.simpleMessage("Verbunden"), "passkey": MessageLookupByLibrary.simpleMessage("Passkey"), "passkeyAuthTitle": MessageLookupByLibrary.simpleMessage("Passkey-Verifizierung"), @@ -1025,18 +1066,21 @@ class MessageLookup extends MessageLookupByLibrary { "passwordChangedSuccessfully": MessageLookupByLibrary.simpleMessage( "Passwort erfolgreich geändert"), "passwordLock": MessageLookupByLibrary.simpleMessage("Passwort Sperre"), - "passwordStrength": m36, + "passwordStrength": m37, "passwordWarning": MessageLookupByLibrary.simpleMessage( "Wir speichern dieses Passwort nicht. Wenn du es vergisst, können wir deine Daten nicht entschlüsseln"), "paymentDetails": MessageLookupByLibrary.simpleMessage("Zahlungsdetails"), "paymentFailed": MessageLookupByLibrary.simpleMessage("Zahlung fehlgeschlagen"), - "paymentFailedTalkToProvider": m37, + "paymentFailedMessage": MessageLookupByLibrary.simpleMessage( + "Leider ist deine Zahlung fehlgeschlagen. Wende dich an unseren Support und wir helfen dir weiter!"), + "paymentFailedTalkToProvider": m38, "pendingItems": MessageLookupByLibrary.simpleMessage("Ausstehende Elemente"), "pendingSync": MessageLookupByLibrary.simpleMessage("Synchronisation anstehend"), + "people": MessageLookupByLibrary.simpleMessage("Personen"), "peopleUsingYourCode": MessageLookupByLibrary.simpleMessage( "Leute, die deinen Code verwenden"), "permDeleteWarning": MessageLookupByLibrary.simpleMessage( @@ -1059,7 +1103,7 @@ class MessageLookup extends MessageLookupByLibrary { "pinAlbum": MessageLookupByLibrary.simpleMessage("Album anheften"), "playOnTv": MessageLookupByLibrary.simpleMessage( "Album auf dem Fernseher wiedergeben"), - "playStoreFreeTrialValidTill": m38, + "playStoreFreeTrialValidTill": m39, "playstoreSubscription": MessageLookupByLibrary.simpleMessage("PlayStore Abo"), "pleaseCheckYourInternetConnectionAndTryAgain": @@ -1071,12 +1115,12 @@ class MessageLookup extends MessageLookupByLibrary { "pleaseContactSupportIfTheProblemPersists": MessageLookupByLibrary.simpleMessage( "Bitte wenden Sie sich an den Support, falls das Problem weiterhin besteht"), - "pleaseEmailUsAt": m39, + "pleaseEmailUsAt": m40, "pleaseGrantPermissions": MessageLookupByLibrary.simpleMessage( "Bitte erteile die nötigen Berechtigungen"), "pleaseLoginAgain": MessageLookupByLibrary.simpleMessage("Bitte logge dich erneut ein"), - "pleaseSendTheLogsTo": m40, + "pleaseSendTheLogsTo": m41, "pleaseTryAgain": MessageLookupByLibrary.simpleMessage("Bitte versuche es erneut"), "pleaseVerifyTheCodeYouHaveEntered": @@ -1112,7 +1156,7 @@ class MessageLookup extends MessageLookupByLibrary { "raiseTicket": MessageLookupByLibrary.simpleMessage("Ticket erstellen"), "rateTheApp": MessageLookupByLibrary.simpleMessage("App bewerten"), "rateUs": MessageLookupByLibrary.simpleMessage("Bewerte uns"), - "rateUsOnStore": m41, + "rateUsOnStore": m42, "recover": MessageLookupByLibrary.simpleMessage("Wiederherstellen"), "recoverAccount": MessageLookupByLibrary.simpleMessage("Konto wiederherstellen"), @@ -1127,7 +1171,7 @@ class MessageLookup extends MessageLookupByLibrary { "recoveryKeySaveDescription": MessageLookupByLibrary.simpleMessage( "Wir speichern diesen Schlüssel nicht. Bitte speichere diese Schlüssel aus 24 Wörtern an einem sicheren Ort."), "recoveryKeySuccessBody": MessageLookupByLibrary.simpleMessage( - "Sehr gut! Ihr Wiederherstellungsschlüssel ist gültig. Vielen Dank für die Verifizierung.\n\nBitte vergessen Sie nicht eine Kopie Ihres Wiederherstellungsschlüssels sicher aufzubewahren."), + "Sehr gut! Dein Wiederherstellungsschlüssel ist gültig. Vielen Dank für die Verifizierung.\n\nBitte vergiss nicht eine Kopie des Wiederherstellungsschlüssels sicher aufzubewahren."), "recoveryKeyVerified": MessageLookupByLibrary.simpleMessage( "Wiederherstellungs-Schlüssel überprüft"), "recoveryKeyVerifyReason": MessageLookupByLibrary.simpleMessage( @@ -1145,7 +1189,7 @@ class MessageLookup extends MessageLookupByLibrary { "1. Gib diesen Code an deine Freunde"), "referralStep2": MessageLookupByLibrary.simpleMessage( "2. Sie schließen ein bezahltes Abo ab"), - "referralStep3": m42, + "referralStep3": m43, "referrals": MessageLookupByLibrary.simpleMessage("Weiterempfehlungen"), "referralsAreCurrentlyPaused": MessageLookupByLibrary.simpleMessage( "Einlösungen sind derzeit pausiert"), @@ -1171,9 +1215,9 @@ class MessageLookup extends MessageLookupByLibrary { "removeLink": MessageLookupByLibrary.simpleMessage("Link entfernen"), "removeParticipant": MessageLookupByLibrary.simpleMessage("Teilnehmer entfernen"), - "removeParticipantBody": m43, + "removeParticipantBody": m44, "removePersonLabel": - MessageLookupByLibrary.simpleMessage("Remove person label"), + MessageLookupByLibrary.simpleMessage("Personenetikett entfernen"), "removePublicLink": MessageLookupByLibrary.simpleMessage("Öffentlichen Link entfernen"), "removeShareItemsWarning": MessageLookupByLibrary.simpleMessage( @@ -1187,7 +1231,7 @@ class MessageLookup extends MessageLookupByLibrary { "renameFile": MessageLookupByLibrary.simpleMessage("Datei umbenennen"), "renewSubscription": MessageLookupByLibrary.simpleMessage("Abonnement erneuern"), - "renewsOn": m44, + "renewsOn": m45, "reportABug": MessageLookupByLibrary.simpleMessage("Fehler melden"), "reportBug": MessageLookupByLibrary.simpleMessage("Fehler melden"), "resendEmail": @@ -1223,7 +1267,7 @@ class MessageLookup extends MessageLookupByLibrary { "scanThisBarcodeWithnyourAuthenticatorApp": MessageLookupByLibrary.simpleMessage( "Scanne diesen Code mit \ndeiner Authentifizierungs-App"), - "search": MessageLookupByLibrary.simpleMessage("Search"), + "search": MessageLookupByLibrary.simpleMessage("Suche"), "searchAlbumsEmptySection": MessageLookupByLibrary.simpleMessage("Alben"), "searchByAlbumNameHint": @@ -1235,7 +1279,7 @@ class MessageLookup extends MessageLookupByLibrary { "searchDatesEmptySection": MessageLookupByLibrary.simpleMessage( "Suche nach Datum, Monat oder Jahr"), "searchFaceEmptySection": MessageLookupByLibrary.simpleMessage( - "Finde alle Foto von einer Person"), + "Personen werden hier angezeigt, sobald die Indizierung abgeschlossen ist"), "searchFileTypesAndNamesEmptySection": MessageLookupByLibrary.simpleMessage("Dateitypen und -namen"), "searchHint1": MessageLookupByLibrary.simpleMessage( @@ -1251,7 +1295,7 @@ class MessageLookup extends MessageLookupByLibrary { "Gruppiere Fotos, die innerhalb des Radius eines bestimmten Fotos aufgenommen wurden"), "searchPeopleEmptySection": MessageLookupByLibrary.simpleMessage( "Laden Sie Personen ein, damit Sie geteilte Fotos hier einsehen können"), - "searchResultCount": m45, + "searchResultCount": m46, "security": MessageLookupByLibrary.simpleMessage("Sicherheit"), "selectALocation": MessageLookupByLibrary.simpleMessage("Standort auswählen"), @@ -1271,19 +1315,21 @@ class MessageLookup extends MessageLookupByLibrary { "selectYourPlan": MessageLookupByLibrary.simpleMessage("Wähle dein Abo aus"), "selectedFilesAreNotOnEnte": MessageLookupByLibrary.simpleMessage( - "Ausgewählte Dateien sind nicht auf ente"), + "Ausgewählte Dateien sind nicht auf Ente"), "selectedFoldersWillBeEncryptedAndBackedUp": MessageLookupByLibrary.simpleMessage( "Ausgewählte Ordner werden verschlüsselt und gesichert"), "selectedItemsWillBeDeletedFromAllAlbumsAndMoved": MessageLookupByLibrary.simpleMessage( "Ausgewählte Elemente werden aus allen Alben gelöscht und in den Papierkorb verschoben."), - "selectedPhotos": m46, - "selectedPhotosWithYours": m47, + "selectedPhotos": m47, + "selectedPhotosWithYours": m48, "send": MessageLookupByLibrary.simpleMessage("Absenden"), "sendEmail": MessageLookupByLibrary.simpleMessage("E-Mail senden"), "sendInvite": MessageLookupByLibrary.simpleMessage("Einladung senden"), "sendLink": MessageLookupByLibrary.simpleMessage("Link senden"), + "serverEndpoint": + MessageLookupByLibrary.simpleMessage("Server Endpunkt"), "sessionExpired": MessageLookupByLibrary.simpleMessage("Sitzung abgelaufen"), "setAPassword": MessageLookupByLibrary.simpleMessage("Passwort setzen"), @@ -1302,27 +1348,27 @@ class MessageLookup extends MessageLookupByLibrary { "shareAnAlbumNow": MessageLookupByLibrary.simpleMessage("Teile jetzt ein Album"), "shareLink": MessageLookupByLibrary.simpleMessage("Link teilen"), - "shareMyVerificationID": m48, + "shareMyVerificationID": m49, "shareOnlyWithThePeopleYouWant": MessageLookupByLibrary.simpleMessage( "Teile mit ausgewählten Personen"), - "shareTextConfirmOthersVerificationID": m49, + "shareTextConfirmOthersVerificationID": m50, "shareTextRecommendUsingEnte": MessageLookupByLibrary.simpleMessage( - "Lade ente herunter, damit wir einfach Fotos und Videos in höchster Qualität teilen können\n\nhttps://ente.io"), - "shareTextReferralCode": m50, + "Hol dir Ente, damit wir ganz einfach Fotos und Videos in Originalqualität teilen können\n\nhttps://ente.io"), + "shareTextReferralCode": m51, "shareWithNonenteUsers": MessageLookupByLibrary.simpleMessage( "Mit Nicht-Ente-Benutzern teilen"), - "shareWithPeopleSectionTitle": m51, + "shareWithPeopleSectionTitle": m52, "shareYourFirstAlbum": MessageLookupByLibrary.simpleMessage("Teile dein erstes Album"), "sharedAlbumSectionDescription": MessageLookupByLibrary.simpleMessage( - "Erstelle gemeinsame Alben mit anderen ente Benutzern, einschließlich solchen im kostenlosen Tarif."), + "Erstelle gemeinsam mit anderen Ente-Nutzern geteilte Alben, inkl. Nutzern ohne Bezahltarif."), "sharedByMe": MessageLookupByLibrary.simpleMessage("Von mir geteilt"), "sharedByYou": MessageLookupByLibrary.simpleMessage("Von dir geteilt"), "sharedPhotoNotifications": MessageLookupByLibrary.simpleMessage("Neue geteilte Fotos"), "sharedPhotoNotificationsExplanation": MessageLookupByLibrary.simpleMessage( "Erhalte Benachrichtigungen, wenn jemand ein Foto zu einem gemeinsam genutzten Album hinzufügt, dem du angehörst"), - "sharedWith": m52, + "sharedWith": m53, "sharedWithMe": MessageLookupByLibrary.simpleMessage("Mit mir geteilt"), "sharedWithYou": MessageLookupByLibrary.simpleMessage("Mit dir geteilt"), @@ -1337,16 +1383,16 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Andere Geräte abmelden"), "signUpTerms": MessageLookupByLibrary.simpleMessage( "Ich stimme den Nutzungsbedingungen und der Datenschutzerklärung zu"), - "singleFileDeleteFromDevice": m53, + "singleFileDeleteFromDevice": m54, "singleFileDeleteHighlight": MessageLookupByLibrary.simpleMessage( "Es wird aus allen Alben gelöscht."), - "singleFileInBothLocalAndRemote": m54, - "singleFileInRemoteOnly": m55, + "singleFileInBothLocalAndRemote": m55, + "singleFileInRemoteOnly": m56, "skip": MessageLookupByLibrary.simpleMessage("Überspringen"), "social": MessageLookupByLibrary.simpleMessage("Social Media"), "someItemsAreInBothEnteAndYourDevice": MessageLookupByLibrary.simpleMessage( - "Einige Elemente sind sowohl auf ente als auch auf Ihrem Gerät."), + "Einige Elemente sind sowohl auf Ente als auch auf deinem Gerät."), "someOfTheFilesYouAreTryingToDeleteAre": MessageLookupByLibrary.simpleMessage( "Einige der Dateien, die Sie löschen möchten, sind nur auf Ihrem Gerät verfügbar und können nicht wiederhergestellt werden, wenn sie gelöscht wurden"), @@ -1380,16 +1426,20 @@ class MessageLookup extends MessageLookupByLibrary { "startBackup": MessageLookupByLibrary.simpleMessage("Sicherung starten"), "status": MessageLookupByLibrary.simpleMessage("Status"), + "stopCastingBody": MessageLookupByLibrary.simpleMessage( + "Möchtest du die Übertragung beenden?"), + "stopCastingTitle": + MessageLookupByLibrary.simpleMessage("Übertragung beenden"), "storage": MessageLookupByLibrary.simpleMessage("Speicherplatz"), "storageBreakupFamily": MessageLookupByLibrary.simpleMessage("Familie"), "storageBreakupYou": MessageLookupByLibrary.simpleMessage("Sie"), - "storageInGB": m56, + "storageInGB": m57, "storageLimitExceeded": MessageLookupByLibrary.simpleMessage( "Speichergrenze überschritten"), - "storageUsageInfo": m57, + "storageUsageInfo": m58, "strongStrength": MessageLookupByLibrary.simpleMessage("Stark"), - "subAlreadyLinkedErrMessage": m58, - "subWillBeCancelledOn": m59, + "subAlreadyLinkedErrMessage": m59, + "subWillBeCancelledOn": m60, "subscribe": MessageLookupByLibrary.simpleMessage("Abonnieren"), "subscribeToEnableSharing": MessageLookupByLibrary.simpleMessage( "Sieht aus, als sei dein Abonnement abgelaufen. Bitte abonniere, um das Teilen zu aktivieren."), @@ -1406,7 +1456,7 @@ class MessageLookup extends MessageLookupByLibrary { "suggestFeatures": MessageLookupByLibrary.simpleMessage("Verbesserung vorschlagen"), "support": MessageLookupByLibrary.simpleMessage("Support"), - "syncProgress": m60, + "syncProgress": m61, "syncStopped": MessageLookupByLibrary.simpleMessage("Synchronisierung angehalten"), "syncing": MessageLookupByLibrary.simpleMessage("Synchronisiere …"), @@ -1435,7 +1485,7 @@ class MessageLookup extends MessageLookupByLibrary { "theseItemsWillBeDeletedFromYourDevice": MessageLookupByLibrary.simpleMessage( "Diese Elemente werden von deinem Gerät gelöscht."), - "theyAlsoGetXGb": m61, + "theyAlsoGetXGb": m62, "theyWillBeDeletedFromAllAlbums": MessageLookupByLibrary.simpleMessage( "Sie werden aus allen Alben gelöscht."), "thisActionCannotBeUndone": MessageLookupByLibrary.simpleMessage( @@ -1451,7 +1501,7 @@ class MessageLookup extends MessageLookupByLibrary { "Diese E-Mail-Adresse wird bereits verwendet"), "thisImageHasNoExifData": MessageLookupByLibrary.simpleMessage( "Dieses Bild hat keine Exif-Daten"), - "thisIsPersonVerificationId": m62, + "thisIsPersonVerificationId": m63, "thisIsYourVerificationId": MessageLookupByLibrary.simpleMessage( "Dies ist deine Verifizierungs-ID"), "thisWillLogYouOutOfTheFollowingDevice": @@ -1462,16 +1512,16 @@ class MessageLookup extends MessageLookupByLibrary { "toHideAPhotoOrVideo": MessageLookupByLibrary.simpleMessage("Foto oder Video verstecken"), "toResetVerifyEmail": MessageLookupByLibrary.simpleMessage( - "Um Ihr Passwort zurückzusetzen, verifizieren Sie bitte zuerst Ihre E-Mail Adresse."), + "Um dein Passwort zurückzusetzen, verifiziere bitte zuerst deine E-Mail Adresse."), "todaysLogs": MessageLookupByLibrary.simpleMessage("Heutiges Protokoll"), "total": MessageLookupByLibrary.simpleMessage("Gesamt"), "totalSize": MessageLookupByLibrary.simpleMessage("Gesamtgröße"), "trash": MessageLookupByLibrary.simpleMessage("Papierkorb"), - "trashDaysLeft": m63, + "trashDaysLeft": m64, "tryAgain": MessageLookupByLibrary.simpleMessage("Erneut versuchen"), "turnOnBackupForAutoUpload": MessageLookupByLibrary.simpleMessage( - "Aktiviere die Sicherung, um automatisch neu hinzugefügte Dateien dieses Ordners auf ente hochzuladen."), + "Aktiviere die Sicherung, um neue Dateien in diesem Ordner automatisch zu Ente hochzuladen."), "twitter": MessageLookupByLibrary.simpleMessage("Twitter"), "twoMonthsFreeOnYearlyPlans": MessageLookupByLibrary.simpleMessage( "2 Monate kostenlos beim jährlichen Bezahlen"), @@ -1514,16 +1564,15 @@ class MessageLookup extends MessageLookupByLibrary { "Bis zu 50% Rabatt bis zum 4. Dezember."), "usableReferralStorageInfo": MessageLookupByLibrary.simpleMessage( "Der verwendbare Speicherplatz ist von deinem aktuellen Abonnement eingeschränkt. Überschüssiger, beanspruchter Speicherplatz wird automatisch verwendbar werden, wenn du ein höheres Abonnement buchst."), - "usePublicLinksForPeopleNotOnEnte": - MessageLookupByLibrary.simpleMessage( - "Nutze öffentliche Links für Personen ohne ente.io Konto"), + "usePublicLinksForPeopleNotOnEnte": MessageLookupByLibrary.simpleMessage( + "Verwenden Sie öffentliche Links für Personen, die kein Ente-Konto haben"), "useRecoveryKey": MessageLookupByLibrary.simpleMessage( "Wiederherstellungs-Schlüssel verwenden"), "useSelectedPhoto": MessageLookupByLibrary.simpleMessage("Ausgewähltes Foto verwenden"), "usedSpace": MessageLookupByLibrary.simpleMessage("Belegter Speicherplatz"), - "validTill": m64, + "validTill": m65, "verificationFailedPleaseTryAgain": MessageLookupByLibrary.simpleMessage( "Verifizierung fehlgeschlagen, bitte versuchen Sie es erneut"), @@ -1532,7 +1581,7 @@ class MessageLookup extends MessageLookupByLibrary { "verify": MessageLookupByLibrary.simpleMessage("Überprüfen"), "verifyEmail": MessageLookupByLibrary.simpleMessage("E-Mail-Adresse verifizieren"), - "verifyEmailID": m65, + "verifyEmailID": m66, "verifyIDLabel": MessageLookupByLibrary.simpleMessage("Überprüfen"), "verifyPasskey": MessageLookupByLibrary.simpleMessage("Passkey verifizieren"), @@ -1565,12 +1614,12 @@ class MessageLookup extends MessageLookupByLibrary { "weDontSupportEditingPhotosAndAlbumsThatYouDont": MessageLookupByLibrary.simpleMessage( "Wir unterstützen keine Bearbeitung von Fotos und Alben, die du noch nicht besitzt"), - "weHaveSendEmailTo": m66, + "weHaveSendEmailTo": m67, "weakStrength": MessageLookupByLibrary.simpleMessage("Schwach"), "welcomeBack": MessageLookupByLibrary.simpleMessage("Willkommen zurück!"), "yearly": MessageLookupByLibrary.simpleMessage("Jährlich"), - "yearsAgo": m67, + "yearsAgo": m68, "yes": MessageLookupByLibrary.simpleMessage("Ja"), "yesCancel": MessageLookupByLibrary.simpleMessage("Ja, kündigen"), "yesConvertToViewer": MessageLookupByLibrary.simpleMessage( @@ -1600,7 +1649,7 @@ class MessageLookup extends MessageLookupByLibrary { "Du kannst nicht mit dir selbst teilen"), "youDontHaveAnyArchivedItems": MessageLookupByLibrary.simpleMessage( "Du hast keine archivierten Elemente."), - "youHaveSuccessfullyFreedUp": m68, + "youHaveSuccessfullyFreedUp": m69, "yourAccountHasBeenDeleted": MessageLookupByLibrary.simpleMessage( "Dein Benutzerkonto wurde gelöscht"), "yourMap": MessageLookupByLibrary.simpleMessage("Deine Karte"), diff --git a/mobile/lib/generated/intl/messages_en.dart b/mobile/lib/generated/intl/messages_en.dart index 3bae850453..b2772c72ea 100644 --- a/mobile/lib/generated/intl/messages_en.dart +++ b/mobile/lib/generated/intl/messages_en.dart @@ -41,176 +41,176 @@ class MessageLookup extends MessageLookupByLibrary { static String m7(versionValue) => "Version: ${versionValue}"; - static String m8(paymentProvider) => + static String m8(freeAmount, storageUnit) => + "${freeAmount} ${storageUnit} free"; + + static String m9(paymentProvider) => "Please cancel your existing subscription from ${paymentProvider} first"; - static String m9(user) => + static String m10(user) => "${user} will not be able to add more photos to this album\n\nThey will still be able to remove existing photos added by them"; - static String m10(isFamilyMember, storageAmountInGb) => + static String m11(isFamilyMember, storageAmountInGb) => "${Intl.select(isFamilyMember, { 'true': 'Your family has claimed ${storageAmountInGb} GB so far', 'false': 'You have claimed ${storageAmountInGb} GB so far', 'other': 'You have claimed ${storageAmountInGb} GB so far!', })}"; - static String m11(albumName) => "Collaborative link created for ${albumName}"; + static String m12(albumName) => "Collaborative link created for ${albumName}"; - static String m12(familyAdminEmail) => + static String m13(familyAdminEmail) => "Please contact ${familyAdminEmail} to manage your subscription"; - static String m13(provider) => + static String m14(provider) => "Please contact us at support@ente.io to manage your ${provider} subscription."; - static String m69(endpoint) => "Connected to ${endpoint}"; + static String m15(endpoint) => "Connected to ${endpoint}"; - static String m14(count) => + static String m16(count) => "${Intl.plural(count, one: 'Delete ${count} item', other: 'Delete ${count} items')}"; - static String m15(currentlyDeleting, totalCount) => + static String m17(currentlyDeleting, totalCount) => "Deleting ${currentlyDeleting} / ${totalCount}"; - static String m16(albumName) => + static String m18(albumName) => "This will remove the public link for accessing \"${albumName}\"."; - static String m17(supportEmail) => + static String m19(supportEmail) => "Please drop an email to ${supportEmail} from your registered email address"; - static String m18(count, storageSaved) => + static String m20(count, storageSaved) => "You have cleaned up ${Intl.plural(count, one: '${count} duplicate file', other: '${count} duplicate files')}, saving (${storageSaved}!)"; - static String m19(count, formattedSize) => + static String m21(count, formattedSize) => "${count} files, ${formattedSize} each"; - static String m20(newEmail) => "Email changed to ${newEmail}"; + static String m22(newEmail) => "Email changed to ${newEmail}"; - static String m21(email) => + static String m23(email) => "${email} does not have an Ente account.\n\nSend them an invite to share photos."; - static String m22(count, formattedNumber) => + static String m24(count, formattedNumber) => "${Intl.plural(count, one: '1 file', other: '${formattedNumber} files')} on this device have been backed up safely"; - static String m23(count, formattedNumber) => + static String m25(count, formattedNumber) => "${Intl.plural(count, one: '1 file', other: '${formattedNumber} files')} in this album has been backed up safely"; - static String m24(storageAmountInGB) => + static String m26(storageAmountInGB) => "${storageAmountInGB} GB each time someone signs up for a paid plan and applies your code"; - static String m25(freeAmount, storageUnit) => - "${freeAmount} ${storageUnit} free"; + static String m27(endDate) => "Free trial valid till ${endDate}"; - static String m26(endDate) => "Free trial valid till ${endDate}"; - - static String m27(count) => + static String m28(count) => "You can still access ${Intl.plural(count, one: 'it', other: 'them')} on Ente as long as you have an active subscription"; - static String m28(sizeInMBorGB) => "Free up ${sizeInMBorGB}"; + static String m29(sizeInMBorGB) => "Free up ${sizeInMBorGB}"; - static String m29(count, formattedSize) => + static String m30(count, formattedSize) => "${Intl.plural(count, one: 'It can be deleted from the device to free up ${formattedSize}', other: 'They can be deleted from the device to free up ${formattedSize}')}"; - static String m30(currentlyProcessing, totalCount) => + static String m31(currentlyProcessing, totalCount) => "Processing ${currentlyProcessing} / ${totalCount}"; - static String m31(count) => + static String m32(count) => "${Intl.plural(count, one: '${count} item', other: '${count} items')}"; - static String m32(expiryTime) => "Link will expire on ${expiryTime}"; + static String m33(expiryTime) => "Link will expire on ${expiryTime}"; - static String m33(count, formattedCount) => + static String m34(count, formattedCount) => "${Intl.plural(count, zero: 'no memories', one: '${formattedCount} memory', other: '${formattedCount} memories')}"; - static String m34(count) => + static String m35(count) => "${Intl.plural(count, one: 'Move item', other: 'Move items')}"; - static String m35(albumName) => "Moved successfully to ${albumName}"; + static String m36(albumName) => "Moved successfully to ${albumName}"; - static String m36(passwordStrengthValue) => + static String m37(passwordStrengthValue) => "Password strength: ${passwordStrengthValue}"; - static String m37(providerName) => + static String m38(providerName) => "Please talk to ${providerName} support if you were charged"; - static String m38(endDate) => + static String m39(endDate) => "Free trial valid till ${endDate}.\nYou can choose a paid plan afterwards."; - static String m39(toEmail) => "Please email us at ${toEmail}"; + static String m40(toEmail) => "Please email us at ${toEmail}"; - static String m40(toEmail) => "Please send the logs to \n${toEmail}"; + static String m41(toEmail) => "Please send the logs to \n${toEmail}"; - static String m41(storeName) => "Rate us on ${storeName}"; + static String m42(storeName) => "Rate us on ${storeName}"; - static String m42(storageInGB) => + static String m43(storageInGB) => "3. Both of you get ${storageInGB} GB* free"; - static String m43(userEmail) => + static String m44(userEmail) => "${userEmail} will be removed from this shared album\n\nAny photos added by them will also be removed from the album"; - static String m44(endDate) => "Subscription renews on ${endDate}"; + static String m45(endDate) => "Subscription renews on ${endDate}"; - static String m45(count) => + static String m46(count) => "${Intl.plural(count, one: '${count} result found', other: '${count} results found')}"; - static String m46(count) => "${count} selected"; + static String m47(count) => "${count} selected"; - static String m47(count, yourCount) => + static String m48(count, yourCount) => "${count} selected (${yourCount} yours)"; - static String m48(verificationID) => + static String m49(verificationID) => "Here\'s my verification ID: ${verificationID} for ente.io."; - static String m49(verificationID) => + static String m50(verificationID) => "Hey, can you confirm that this is your ente.io verification ID: ${verificationID}"; - static String m50(referralCode, referralStorageInGB) => + static String m51(referralCode, referralStorageInGB) => "Ente referral code: ${referralCode} \n\nApply it in Settings → General → Referrals to get ${referralStorageInGB} GB free after you signup for a paid plan\n\nhttps://ente.io"; - static String m51(numberOfPeople) => + static String m52(numberOfPeople) => "${Intl.plural(numberOfPeople, zero: 'Share with specific people', one: 'Shared with 1 person', other: 'Shared with ${numberOfPeople} people')}"; - static String m52(emailIDs) => "Shared with ${emailIDs}"; - - static String m53(fileType) => - "This ${fileType} will be deleted from your device."; + static String m53(emailIDs) => "Shared with ${emailIDs}"; static String m54(fileType) => + "This ${fileType} will be deleted from your device."; + + static String m55(fileType) => "This ${fileType} is in both Ente and your device."; - static String m55(fileType) => "This ${fileType} will be deleted from Ente."; + static String m56(fileType) => "This ${fileType} will be deleted from Ente."; - static String m56(storageAmountInGB) => "${storageAmountInGB} GB"; + static String m57(storageAmountInGB) => "${storageAmountInGB} GB"; - static String m57( + static String m58( usedAmount, usedStorageUnit, totalAmount, totalStorageUnit) => "${usedAmount} ${usedStorageUnit} of ${totalAmount} ${totalStorageUnit} used"; - static String m58(id) => + static String m59(id) => "Your ${id} is already linked to another Ente account.\nIf you would like to use your ${id} with this account, please contact our support\'\'"; - static String m59(endDate) => + static String m60(endDate) => "Your subscription will be cancelled on ${endDate}"; - static String m60(completed, total) => + static String m61(completed, total) => "${completed}/${total} memories preserved"; - static String m61(storageAmountInGB) => + static String m62(storageAmountInGB) => "They also get ${storageAmountInGB} GB"; - static String m62(email) => "This is ${email}\'s Verification ID"; + static String m63(email) => "This is ${email}\'s Verification ID"; - static String m63(count) => + static String m64(count) => "${Intl.plural(count, zero: '', one: '1 day', other: '${count} days')}"; - static String m64(endDate) => "Valid till ${endDate}"; + static String m65(endDate) => "Valid till ${endDate}"; - static String m65(email) => "Verify ${email}"; + static String m66(email) => "Verify ${email}"; - static String m66(email) => "We have sent a mail to ${email}"; + static String m67(email) => "We have sent a mail to ${email}"; - static String m67(count) => + static String m68(count) => "${Intl.plural(count, one: '${count} year ago', other: '${count} years ago')}"; - static String m68(storageSaved) => + static String m69(storageSaved) => "You have successfully freed up ${storageSaved}!"; final messages = _notInlinedMessages(_notInlinedMessages); @@ -368,6 +368,7 @@ class MessageLookup extends MessageLookupByLibrary { "autoPairDesc": MessageLookupByLibrary.simpleMessage( "Auto pair works only with devices that support Chromecast."), "available": MessageLookupByLibrary.simpleMessage("Available"), + "availableStorageSpace": m8, "backedUpFolders": MessageLookupByLibrary.simpleMessage("Backed up folders"), "backup": MessageLookupByLibrary.simpleMessage("Backup"), @@ -391,10 +392,10 @@ class MessageLookup extends MessageLookupByLibrary { "canOnlyRemoveFilesOwnedByYou": MessageLookupByLibrary.simpleMessage( "Can only remove files owned by you"), "cancel": MessageLookupByLibrary.simpleMessage("Cancel"), - "cancelOtherSubscription": m8, + "cancelOtherSubscription": m9, "cancelSubscription": MessageLookupByLibrary.simpleMessage("Cancel subscription"), - "cannotAddMorePhotosAfterBecomingViewer": m9, + "cannotAddMorePhotosAfterBecomingViewer": m10, "cannotDeleteSharedFiles": MessageLookupByLibrary.simpleMessage("Cannot delete shared files"), "castIPMismatchBody": MessageLookupByLibrary.simpleMessage( @@ -417,12 +418,13 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Check for updates"), "checkInboxAndSpamFolder": MessageLookupByLibrary.simpleMessage( "Please check your inbox (and spam) to complete verification"), + "checkStatus": MessageLookupByLibrary.simpleMessage("Check status"), "checking": MessageLookupByLibrary.simpleMessage("Checking..."), "claimFreeStorage": MessageLookupByLibrary.simpleMessage("Claim free storage"), "claimMore": MessageLookupByLibrary.simpleMessage("Claim more!"), "claimed": MessageLookupByLibrary.simpleMessage("Claimed"), - "claimedStorageSoFar": m10, + "claimedStorageSoFar": m11, "cleanUncategorized": MessageLookupByLibrary.simpleMessage("Clean Uncategorized"), "cleanUncategorizedDescription": MessageLookupByLibrary.simpleMessage( @@ -449,7 +451,7 @@ class MessageLookup extends MessageLookupByLibrary { "Create a link to allow people to add and view photos in your shared album without needing an Ente app or account. Great for collecting event photos."), "collaborativeLink": MessageLookupByLibrary.simpleMessage("Collaborative link"), - "collaborativeLinkCreatedFor": m11, + "collaborativeLinkCreatedFor": m12, "collaborator": MessageLookupByLibrary.simpleMessage("Collaborator"), "collaboratorsCanAddPhotosAndVideosToTheSharedAlbum": MessageLookupByLibrary.simpleMessage( @@ -478,10 +480,10 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Confirm your recovery key"), "connectToDevice": MessageLookupByLibrary.simpleMessage("Connect to device"), - "contactFamilyAdmin": m12, + "contactFamilyAdmin": m13, "contactSupport": MessageLookupByLibrary.simpleMessage("Contact support"), - "contactToManageSubscription": m13, + "contactToManageSubscription": m14, "contacts": MessageLookupByLibrary.simpleMessage("Contacts"), "contents": MessageLookupByLibrary.simpleMessage("Contents"), "continueLabel": MessageLookupByLibrary.simpleMessage("Continue"), @@ -521,10 +523,11 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Creating link..."), "criticalUpdateAvailable": MessageLookupByLibrary.simpleMessage("Critical update available"), + "crop": MessageLookupByLibrary.simpleMessage("Crop"), "currentUsageIs": MessageLookupByLibrary.simpleMessage("Current usage is "), "custom": MessageLookupByLibrary.simpleMessage("Custom"), - "customEndpoint": m69, + "customEndpoint": m15, "darkTheme": MessageLookupByLibrary.simpleMessage("Dark"), "dayToday": MessageLookupByLibrary.simpleMessage("Today"), "dayYesterday": MessageLookupByLibrary.simpleMessage("Yesterday"), @@ -559,11 +562,11 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Delete from device"), "deleteFromEnte": MessageLookupByLibrary.simpleMessage("Delete from Ente"), - "deleteItemCount": m14, + "deleteItemCount": m16, "deleteLocation": MessageLookupByLibrary.simpleMessage("Delete location"), "deletePhotos": MessageLookupByLibrary.simpleMessage("Delete photos"), - "deleteProgress": m15, + "deleteProgress": m17, "deleteReason1": MessageLookupByLibrary.simpleMessage( "It’s missing a key feature that I need"), "deleteReason2": MessageLookupByLibrary.simpleMessage( @@ -602,7 +605,7 @@ class MessageLookup extends MessageLookupByLibrary { "Viewers can still take screenshots or save a copy of your photos using external tools"), "disableDownloadWarningTitle": MessageLookupByLibrary.simpleMessage("Please note"), - "disableLinkMessage": m16, + "disableLinkMessage": m18, "disableTwofactor": MessageLookupByLibrary.simpleMessage("Disable two-factor"), "disablingTwofactorAuthentication": @@ -623,9 +626,9 @@ class MessageLookup extends MessageLookupByLibrary { "downloadFailed": MessageLookupByLibrary.simpleMessage("Download failed"), "downloading": MessageLookupByLibrary.simpleMessage("Downloading..."), - "dropSupportEmail": m17, - "duplicateFileCountWithStorageSaved": m18, - "duplicateItemsGroup": m19, + "dropSupportEmail": m19, + "duplicateFileCountWithStorageSaved": m20, + "duplicateItemsGroup": m21, "edit": MessageLookupByLibrary.simpleMessage("Edit"), "editLocation": MessageLookupByLibrary.simpleMessage("Edit location"), "editLocationTagTitle": @@ -636,8 +639,8 @@ class MessageLookup extends MessageLookupByLibrary { "Edits to location will only be seen within Ente"), "eligible": MessageLookupByLibrary.simpleMessage("eligible"), "email": MessageLookupByLibrary.simpleMessage("Email"), - "emailChangedTo": m20, - "emailNoEnteAccount": m21, + "emailChangedTo": m22, + "emailNoEnteAccount": m23, "emailVerificationToggle": MessageLookupByLibrary.simpleMessage("Email verification"), "emailYourLogs": @@ -738,8 +741,8 @@ class MessageLookup extends MessageLookupByLibrary { "fileTypes": MessageLookupByLibrary.simpleMessage("File types"), "fileTypesAndNames": MessageLookupByLibrary.simpleMessage("File types and names"), - "filesBackedUpFromDevice": m22, - "filesBackedUpInAlbum": m23, + "filesBackedUpFromDevice": m24, + "filesBackedUpInAlbum": m25, "filesDeleted": MessageLookupByLibrary.simpleMessage("Files deleted"), "filesSavedToGallery": MessageLookupByLibrary.simpleMessage("Files saved to gallery"), @@ -753,24 +756,25 @@ class MessageLookup extends MessageLookupByLibrary { "foundFaces": MessageLookupByLibrary.simpleMessage("Found faces"), "freeStorageClaimed": MessageLookupByLibrary.simpleMessage("Free storage claimed"), - "freeStorageOnReferralSuccess": m24, - "freeStorageSpace": m25, + "freeStorageOnReferralSuccess": m26, "freeStorageUsable": MessageLookupByLibrary.simpleMessage("Free storage usable"), "freeTrial": MessageLookupByLibrary.simpleMessage("Free trial"), - "freeTrialValidTill": m26, - "freeUpAccessPostDelete": m27, - "freeUpAmount": m28, + "freeTrialValidTill": m27, + "freeUpAccessPostDelete": m28, + "freeUpAmount": m29, "freeUpDeviceSpace": MessageLookupByLibrary.simpleMessage("Free up device space"), + "freeUpDeviceSpaceDesc": MessageLookupByLibrary.simpleMessage( + "Save space on your device by clearing files that have been already backed up."), "freeUpSpace": MessageLookupByLibrary.simpleMessage("Free up space"), - "freeUpSpaceSaving": m29, + "freeUpSpaceSaving": m30, "galleryMemoryLimitInfo": MessageLookupByLibrary.simpleMessage( "Up to 1000 memories shown in gallery"), "general": MessageLookupByLibrary.simpleMessage("General"), "generatingEncryptionKeys": MessageLookupByLibrary.simpleMessage( "Generating encryption keys..."), - "genericProgress": m30, + "genericProgress": m31, "goToSettings": MessageLookupByLibrary.simpleMessage("Go to settings"), "googlePlayId": MessageLookupByLibrary.simpleMessage("Google Play ID"), "grantFullAccessPrompt": MessageLookupByLibrary.simpleMessage( @@ -835,7 +839,7 @@ class MessageLookup extends MessageLookupByLibrary { "itLooksLikeSomethingWentWrongPleaseRetryAfterSome": MessageLookupByLibrary.simpleMessage( "It looks like something went wrong. Please retry after some time. If the error persists, please contact our support team."), - "itemCount": m31, + "itemCount": m32, "itemsShowTheNumberOfDaysRemainingBeforePermanentDeletion": MessageLookupByLibrary.simpleMessage( "Items show the number of days remaining before permanent deletion"), @@ -853,6 +857,7 @@ class MessageLookup extends MessageLookupByLibrary { "leaveFamily": MessageLookupByLibrary.simpleMessage("Leave family"), "leaveSharedAlbum": MessageLookupByLibrary.simpleMessage("Leave shared album?"), + "left": MessageLookupByLibrary.simpleMessage("Left"), "light": MessageLookupByLibrary.simpleMessage("Light"), "lightTheme": MessageLookupByLibrary.simpleMessage("Light"), "linkCopiedToClipboard": @@ -860,7 +865,7 @@ class MessageLookup extends MessageLookupByLibrary { "linkDeviceLimit": MessageLookupByLibrary.simpleMessage("Device limit"), "linkEnabled": MessageLookupByLibrary.simpleMessage("Enabled"), "linkExpired": MessageLookupByLibrary.simpleMessage("Expired"), - "linkExpiresOn": m32, + "linkExpiresOn": m33, "linkExpiry": MessageLookupByLibrary.simpleMessage("Link expiry"), "linkHasExpired": MessageLookupByLibrary.simpleMessage("Link has expired"), @@ -904,6 +909,10 @@ class MessageLookup extends MessageLookupByLibrary { "lockscreen": MessageLookupByLibrary.simpleMessage("Lockscreen"), "logInLabel": MessageLookupByLibrary.simpleMessage("Log in"), "loggingOut": MessageLookupByLibrary.simpleMessage("Logging out..."), + "loginSessionExpired": + MessageLookupByLibrary.simpleMessage("Session expired"), + "loginSessionExpiredDetails": MessageLookupByLibrary.simpleMessage( + "Your session has expired. Please login again."), "loginTerms": MessageLookupByLibrary.simpleMessage( "By clicking log in, I agree to the terms of service and privacy policy"), "logout": MessageLookupByLibrary.simpleMessage("Logout"), @@ -933,7 +942,7 @@ class MessageLookup extends MessageLookupByLibrary { "maps": MessageLookupByLibrary.simpleMessage("Maps"), "mastodon": MessageLookupByLibrary.simpleMessage("Mastodon"), "matrix": MessageLookupByLibrary.simpleMessage("Matrix"), - "memoryCount": m33, + "memoryCount": m34, "merchandise": MessageLookupByLibrary.simpleMessage("Merchandise"), "mlIndexingDescription": MessageLookupByLibrary.simpleMessage( "Please note that machine learning will result in a higher bandwidth and battery usage until all items are indexed."), @@ -945,11 +954,11 @@ class MessageLookup extends MessageLookupByLibrary { "Modify your query, or try searching for"), "moments": MessageLookupByLibrary.simpleMessage("Moments"), "monthly": MessageLookupByLibrary.simpleMessage("Monthly"), - "moveItem": m34, + "moveItem": m35, "moveToAlbum": MessageLookupByLibrary.simpleMessage("Move to album"), "moveToHiddenAlbum": MessageLookupByLibrary.simpleMessage("Move to hidden album"), - "movedSuccessfullyTo": m35, + "movedSuccessfullyTo": m36, "movedToTrash": MessageLookupByLibrary.simpleMessage("Moved to trash"), "movingFilesToAlbum": MessageLookupByLibrary.simpleMessage("Moving files to album..."), @@ -1016,6 +1025,8 @@ class MessageLookup extends MessageLookupByLibrary { "pairWithPin": MessageLookupByLibrary.simpleMessage("Pair with PIN"), "pairingComplete": MessageLookupByLibrary.simpleMessage("Pairing complete"), + "passKeyPendingVerification": MessageLookupByLibrary.simpleMessage( + "Verification is still pending"), "passkey": MessageLookupByLibrary.simpleMessage("Passkey"), "passkeyAuthTitle": MessageLookupByLibrary.simpleMessage("Passkey verification"), @@ -1023,7 +1034,7 @@ class MessageLookup extends MessageLookupByLibrary { "passwordChangedSuccessfully": MessageLookupByLibrary.simpleMessage( "Password changed successfully"), "passwordLock": MessageLookupByLibrary.simpleMessage("Password lock"), - "passwordStrength": m36, + "passwordStrength": m37, "passwordWarning": MessageLookupByLibrary.simpleMessage( "We don\'t store this password, so if you forget, we cannot decrypt your data"), "paymentDetails": @@ -1031,7 +1042,7 @@ class MessageLookup extends MessageLookupByLibrary { "paymentFailed": MessageLookupByLibrary.simpleMessage("Payment failed"), "paymentFailedMessage": MessageLookupByLibrary.simpleMessage( "Unfortunately your payment failed. Please contact support and we\'ll help you out!"), - "paymentFailedTalkToProvider": m37, + "paymentFailedTalkToProvider": m38, "pendingItems": MessageLookupByLibrary.simpleMessage("Pending items"), "pendingSync": MessageLookupByLibrary.simpleMessage("Pending sync"), "people": MessageLookupByLibrary.simpleMessage("People"), @@ -1056,7 +1067,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Pick center point"), "pinAlbum": MessageLookupByLibrary.simpleMessage("Pin album"), "playOnTv": MessageLookupByLibrary.simpleMessage("Play album on TV"), - "playStoreFreeTrialValidTill": m38, + "playStoreFreeTrialValidTill": m39, "playstoreSubscription": MessageLookupByLibrary.simpleMessage("PlayStore subscription"), "pleaseCheckYourInternetConnectionAndTryAgain": @@ -1068,12 +1079,12 @@ class MessageLookup extends MessageLookupByLibrary { "pleaseContactSupportIfTheProblemPersists": MessageLookupByLibrary.simpleMessage( "Please contact support if the problem persists"), - "pleaseEmailUsAt": m39, + "pleaseEmailUsAt": m40, "pleaseGrantPermissions": MessageLookupByLibrary.simpleMessage("Please grant permissions"), "pleaseLoginAgain": MessageLookupByLibrary.simpleMessage("Please login again"), - "pleaseSendTheLogsTo": m40, + "pleaseSendTheLogsTo": m41, "pleaseTryAgain": MessageLookupByLibrary.simpleMessage("Please try again"), "pleaseVerifyTheCodeYouHaveEntered": @@ -1108,7 +1119,7 @@ class MessageLookup extends MessageLookupByLibrary { "raiseTicket": MessageLookupByLibrary.simpleMessage("Raise ticket"), "rateTheApp": MessageLookupByLibrary.simpleMessage("Rate the app"), "rateUs": MessageLookupByLibrary.simpleMessage("Rate us"), - "rateUsOnStore": m41, + "rateUsOnStore": m42, "recover": MessageLookupByLibrary.simpleMessage("Recover"), "recoverAccount": MessageLookupByLibrary.simpleMessage("Recover account"), @@ -1139,7 +1150,7 @@ class MessageLookup extends MessageLookupByLibrary { "1. Give this code to your friends"), "referralStep2": MessageLookupByLibrary.simpleMessage( "2. They sign up for a paid plan"), - "referralStep3": m42, + "referralStep3": m43, "referrals": MessageLookupByLibrary.simpleMessage("Referrals"), "referralsAreCurrentlyPaused": MessageLookupByLibrary.simpleMessage( "Referrals are currently paused"), @@ -1154,6 +1165,8 @@ class MessageLookup extends MessageLookupByLibrary { "remove": MessageLookupByLibrary.simpleMessage("Remove"), "removeDuplicates": MessageLookupByLibrary.simpleMessage("Remove duplicates"), + "removeDuplicatesDesc": MessageLookupByLibrary.simpleMessage( + "Review and remove files that are exact duplicates."), "removeFromAlbum": MessageLookupByLibrary.simpleMessage("Remove from album"), "removeFromAlbumTitle": @@ -1163,7 +1176,7 @@ class MessageLookup extends MessageLookupByLibrary { "removeLink": MessageLookupByLibrary.simpleMessage("Remove link"), "removeParticipant": MessageLookupByLibrary.simpleMessage("Remove participant"), - "removeParticipantBody": m43, + "removeParticipantBody": m44, "removePersonLabel": MessageLookupByLibrary.simpleMessage("Remove person label"), "removePublicLink": @@ -1179,7 +1192,7 @@ class MessageLookup extends MessageLookupByLibrary { "renameFile": MessageLookupByLibrary.simpleMessage("Rename file"), "renewSubscription": MessageLookupByLibrary.simpleMessage("Renew subscription"), - "renewsOn": m44, + "renewsOn": m45, "reportABug": MessageLookupByLibrary.simpleMessage("Report a bug"), "reportBug": MessageLookupByLibrary.simpleMessage("Report bug"), "resendEmail": MessageLookupByLibrary.simpleMessage("Resend email"), @@ -1197,6 +1210,8 @@ class MessageLookup extends MessageLookupByLibrary { "retry": MessageLookupByLibrary.simpleMessage("Retry"), "reviewDeduplicateItems": MessageLookupByLibrary.simpleMessage( "Please review and delete the items you believe are duplicates."), + "right": MessageLookupByLibrary.simpleMessage("Right"), + "rotate": MessageLookupByLibrary.simpleMessage("Rotate"), "rotateLeft": MessageLookupByLibrary.simpleMessage("Rotate left"), "rotateRight": MessageLookupByLibrary.simpleMessage("Rotate right"), "safelyStored": MessageLookupByLibrary.simpleMessage("Safely stored"), @@ -1208,6 +1223,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage( "Save your recovery key if you haven\'t already"), "saving": MessageLookupByLibrary.simpleMessage("Saving..."), + "savingEdits": MessageLookupByLibrary.simpleMessage("Saving edits..."), "scanCode": MessageLookupByLibrary.simpleMessage("Scan code"), "scanThisBarcodeWithnyourAuthenticatorApp": MessageLookupByLibrary.simpleMessage( @@ -1224,7 +1240,7 @@ class MessageLookup extends MessageLookupByLibrary { "searchDatesEmptySection": MessageLookupByLibrary.simpleMessage( "Search by a date, month or year"), "searchFaceEmptySection": MessageLookupByLibrary.simpleMessage( - "Persons will be shown here once indexing is done"), + "People will be shown here once indexing is done"), "searchFileTypesAndNamesEmptySection": MessageLookupByLibrary.simpleMessage("File types and names"), "searchHint1": @@ -1240,7 +1256,7 @@ class MessageLookup extends MessageLookupByLibrary { "Group photos that are taken within some radius of a photo"), "searchPeopleEmptySection": MessageLookupByLibrary.simpleMessage( "Invite people, and you\'ll see all photos shared by them here"), - "searchResultCount": m45, + "searchResultCount": m46, "security": MessageLookupByLibrary.simpleMessage("Security"), "selectALocation": MessageLookupByLibrary.simpleMessage("Select a location"), @@ -1267,8 +1283,8 @@ class MessageLookup extends MessageLookupByLibrary { "selectedItemsWillBeDeletedFromAllAlbumsAndMoved": MessageLookupByLibrary.simpleMessage( "Selected items will be deleted from all albums and moved to trash."), - "selectedPhotos": m46, - "selectedPhotosWithYours": m47, + "selectedPhotos": m47, + "selectedPhotosWithYours": m48, "send": MessageLookupByLibrary.simpleMessage("Send"), "sendEmail": MessageLookupByLibrary.simpleMessage("Send email"), "sendInvite": MessageLookupByLibrary.simpleMessage("Send invite"), @@ -1292,16 +1308,16 @@ class MessageLookup extends MessageLookupByLibrary { "shareAnAlbumNow": MessageLookupByLibrary.simpleMessage("Share an album now"), "shareLink": MessageLookupByLibrary.simpleMessage("Share link"), - "shareMyVerificationID": m48, + "shareMyVerificationID": m49, "shareOnlyWithThePeopleYouWant": MessageLookupByLibrary.simpleMessage( "Share only with the people you want"), - "shareTextConfirmOthersVerificationID": m49, + "shareTextConfirmOthersVerificationID": m50, "shareTextRecommendUsingEnte": MessageLookupByLibrary.simpleMessage( "Download Ente so we can easily share original quality photos and videos\n\nhttps://ente.io"), - "shareTextReferralCode": m50, + "shareTextReferralCode": m51, "shareWithNonenteUsers": MessageLookupByLibrary.simpleMessage("Share with non-Ente users"), - "shareWithPeopleSectionTitle": m51, + "shareWithPeopleSectionTitle": m52, "shareYourFirstAlbum": MessageLookupByLibrary.simpleMessage("Share your first album"), "sharedAlbumSectionDescription": MessageLookupByLibrary.simpleMessage( @@ -1312,7 +1328,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("New shared photos"), "sharedPhotoNotificationsExplanation": MessageLookupByLibrary.simpleMessage( "Receive notifications when someone adds a photo to a shared album that you\'re a part of"), - "sharedWith": m52, + "sharedWith": m53, "sharedWithMe": MessageLookupByLibrary.simpleMessage("Shared with me"), "sharedWithYou": MessageLookupByLibrary.simpleMessage("Shared with you"), @@ -1326,11 +1342,11 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Sign out other devices"), "signUpTerms": MessageLookupByLibrary.simpleMessage( "I agree to the terms of service and privacy policy"), - "singleFileDeleteFromDevice": m53, + "singleFileDeleteFromDevice": m54, "singleFileDeleteHighlight": MessageLookupByLibrary.simpleMessage( "It will be deleted from all albums."), - "singleFileInBothLocalAndRemote": m54, - "singleFileInRemoteOnly": m55, + "singleFileInBothLocalAndRemote": m55, + "singleFileInRemoteOnly": m56, "skip": MessageLookupByLibrary.simpleMessage("Skip"), "social": MessageLookupByLibrary.simpleMessage("Social"), "someItemsAreInBothEnteAndYourDevice": @@ -1372,13 +1388,13 @@ class MessageLookup extends MessageLookupByLibrary { "storage": MessageLookupByLibrary.simpleMessage("Storage"), "storageBreakupFamily": MessageLookupByLibrary.simpleMessage("Family"), "storageBreakupYou": MessageLookupByLibrary.simpleMessage("You"), - "storageInGB": m56, + "storageInGB": m57, "storageLimitExceeded": MessageLookupByLibrary.simpleMessage("Storage limit exceeded"), - "storageUsageInfo": m57, + "storageUsageInfo": m58, "strongStrength": MessageLookupByLibrary.simpleMessage("Strong"), - "subAlreadyLinkedErrMessage": m58, - "subWillBeCancelledOn": m59, + "subAlreadyLinkedErrMessage": m59, + "subWillBeCancelledOn": m60, "subscribe": MessageLookupByLibrary.simpleMessage("Subscribe"), "subscribeToEnableSharing": MessageLookupByLibrary.simpleMessage( "Looks like your subscription has expired. Please subscribe to enable sharing."), @@ -1395,7 +1411,7 @@ class MessageLookup extends MessageLookupByLibrary { "suggestFeatures": MessageLookupByLibrary.simpleMessage("Suggest features"), "support": MessageLookupByLibrary.simpleMessage("Support"), - "syncProgress": m60, + "syncProgress": m61, "syncStopped": MessageLookupByLibrary.simpleMessage("Sync stopped"), "syncing": MessageLookupByLibrary.simpleMessage("Syncing..."), "systemTheme": MessageLookupByLibrary.simpleMessage("System"), @@ -1421,7 +1437,7 @@ class MessageLookup extends MessageLookupByLibrary { "theseItemsWillBeDeletedFromYourDevice": MessageLookupByLibrary.simpleMessage( "These items will be deleted from your device."), - "theyAlsoGetXGb": m61, + "theyAlsoGetXGb": m62, "theyWillBeDeletedFromAllAlbums": MessageLookupByLibrary.simpleMessage( "They will be deleted from all albums."), "thisActionCannotBeUndone": MessageLookupByLibrary.simpleMessage( @@ -1437,7 +1453,7 @@ class MessageLookup extends MessageLookupByLibrary { "This email is already in use"), "thisImageHasNoExifData": MessageLookupByLibrary.simpleMessage("This image has no exif data"), - "thisIsPersonVerificationId": m62, + "thisIsPersonVerificationId": m63, "thisIsYourVerificationId": MessageLookupByLibrary.simpleMessage( "This is your Verification ID"), "thisWillLogYouOutOfTheFollowingDevice": @@ -1453,7 +1469,8 @@ class MessageLookup extends MessageLookupByLibrary { "total": MessageLookupByLibrary.simpleMessage("total"), "totalSize": MessageLookupByLibrary.simpleMessage("Total size"), "trash": MessageLookupByLibrary.simpleMessage("Trash"), - "trashDaysLeft": m63, + "trashDaysLeft": m64, + "trim": MessageLookupByLibrary.simpleMessage("Trim"), "tryAgain": MessageLookupByLibrary.simpleMessage("Try again"), "turnOnBackupForAutoUpload": MessageLookupByLibrary.simpleMessage( "Turn on backup to automatically upload files added to this device folder to Ente."), @@ -1505,7 +1522,7 @@ class MessageLookup extends MessageLookupByLibrary { "useSelectedPhoto": MessageLookupByLibrary.simpleMessage("Use selected photo"), "usedSpace": MessageLookupByLibrary.simpleMessage("Used space"), - "validTill": m64, + "validTill": m65, "verificationFailedPleaseTryAgain": MessageLookupByLibrary.simpleMessage( "Verification failed, please try again"), @@ -1513,7 +1530,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Verification ID"), "verify": MessageLookupByLibrary.simpleMessage("Verify"), "verifyEmail": MessageLookupByLibrary.simpleMessage("Verify email"), - "verifyEmailID": m65, + "verifyEmailID": m66, "verifyIDLabel": MessageLookupByLibrary.simpleMessage("Verify"), "verifyPasskey": MessageLookupByLibrary.simpleMessage("Verify passkey"), "verifyPassword": @@ -1529,6 +1546,9 @@ class MessageLookup extends MessageLookupByLibrary { "viewAll": MessageLookupByLibrary.simpleMessage("View all"), "viewAllExifData": MessageLookupByLibrary.simpleMessage("View all EXIF data"), + "viewLargeFiles": MessageLookupByLibrary.simpleMessage("Large files"), + "viewLargeFilesDesc": MessageLookupByLibrary.simpleMessage( + "View files that are consuming the most amount of storage"), "viewLogs": MessageLookupByLibrary.simpleMessage("View logs"), "viewRecoveryKey": MessageLookupByLibrary.simpleMessage("View recovery key"), @@ -1544,11 +1564,12 @@ class MessageLookup extends MessageLookupByLibrary { "weDontSupportEditingPhotosAndAlbumsThatYouDont": MessageLookupByLibrary.simpleMessage( "We don\'t support editing photos and albums that you don\'t own yet"), - "weHaveSendEmailTo": m66, + "weHaveSendEmailTo": m67, "weakStrength": MessageLookupByLibrary.simpleMessage("Weak"), "welcomeBack": MessageLookupByLibrary.simpleMessage("Welcome back!"), + "whatsNew": MessageLookupByLibrary.simpleMessage("What\'s new"), "yearly": MessageLookupByLibrary.simpleMessage("Yearly"), - "yearsAgo": m67, + "yearsAgo": m68, "yes": MessageLookupByLibrary.simpleMessage("Yes"), "yesCancel": MessageLookupByLibrary.simpleMessage("Yes, cancel"), "yesConvertToViewer": @@ -1578,7 +1599,7 @@ class MessageLookup extends MessageLookupByLibrary { "You cannot share with yourself"), "youDontHaveAnyArchivedItems": MessageLookupByLibrary.simpleMessage( "You don\'t have any archived items."), - "youHaveSuccessfullyFreedUp": m68, + "youHaveSuccessfullyFreedUp": m69, "yourAccountHasBeenDeleted": MessageLookupByLibrary.simpleMessage( "Your account has been deleted"), "yourMap": MessageLookupByLibrary.simpleMessage("Your map"), diff --git a/mobile/lib/generated/intl/messages_es.dart b/mobile/lib/generated/intl/messages_es.dart index 6db255c566..97f65a4f43 100644 --- a/mobile/lib/generated/intl/messages_es.dart +++ b/mobile/lib/generated/intl/messages_es.dart @@ -38,13 +38,13 @@ class MessageLookup extends MessageLookupByLibrary { static String m7(versionValue) => "Versión: ${versionValue}"; - static String m8(paymentProvider) => + static String m9(paymentProvider) => "Por favor, cancele primero su suscripción existente de ${paymentProvider}"; - static String m9(user) => + static String m10(user) => "${user} no podrá añadir más fotos a este álbum\n\nTodavía podrán eliminar las fotos ya añadidas por ellos"; - static String m10(isFamilyMember, storageAmountInGb) => + static String m11(isFamilyMember, storageAmountInGb) => "${Intl.select(isFamilyMember, { 'true': 'Su familia ha reclamado ${storageAmountInGb} GB hasta el momento', @@ -54,143 +54,140 @@ class MessageLookup extends MessageLookupByLibrary { '¡Tú has reclamado ${storageAmountInGb} GB hasta el momento!', })}"; - static String m11(albumName) => + static String m12(albumName) => "Enlace colaborativo creado para ${albumName}"; - static String m12(familyAdminEmail) => + static String m13(familyAdminEmail) => "Por favor contacta con ${familyAdminEmail} para administrar tu suscripción"; - static String m13(provider) => + static String m14(provider) => "Por favor, contáctenos en support@ente.io para gestionar su suscripción a ${provider}."; - static String m15(currentlyDeleting, totalCount) => + static String m17(currentlyDeleting, totalCount) => "Borrando ${currentlyDeleting} / ${totalCount}"; - static String m16(albumName) => + static String m18(albumName) => "Esto eliminará el enlace público para acceder a \"${albumName}\"."; - static String m17(supportEmail) => + static String m19(supportEmail) => "Por favor, envíe un email a ${supportEmail} desde su dirección de correo electrónico registrada"; - static String m18(count, storageSaved) => + static String m20(count, storageSaved) => "¡Has limpiado ${Intl.plural(count, one: '${count} archivo duplicado', other: '${count} archivos duplicados')}, ahorrando (${storageSaved}!)"; - static String m20(newEmail) => "Correo cambiado a ${newEmail}"; + static String m22(newEmail) => "Correo cambiado a ${newEmail}"; - static String m21(email) => + static String m23(email) => "${email} no tiene una cuenta ente.\n\nEnvíale una invitación para compartir fotos."; - static String m22(count, formattedNumber) => + static String m24(count, formattedNumber) => "${Intl.plural(count, one: '1 archivo', other: '${formattedNumber} archivos')} en este dispositivo han sido respaldados de forma segura"; - static String m23(count, formattedNumber) => + static String m25(count, formattedNumber) => "${Intl.plural(count, one: '1 archivo', other: '${formattedNumber} archivos')} en este álbum ha sido respaldado de forma segura"; - static String m24(storageAmountInGB) => + static String m26(storageAmountInGB) => "${storageAmountInGB} GB cada vez que alguien se registra en un plan de pago y aplica tu código"; - static String m25(freeAmount, storageUnit) => - "${freeAmount} ${storageUnit} gratis"; + static String m27(endDate) => "Prueba gratuita válida hasta${endDate}"; - static String m26(endDate) => "Prueba gratuita válida hasta${endDate}"; - - static String m27(count) => + static String m28(count) => "Aún puedes acceder ${Intl.plural(count, one: 'si', other: 'entonces')} en ente mientras mantengas una suscripción activa"; - static String m28(sizeInMBorGB) => "Liberar ${sizeInMBorGB}"; + static String m29(sizeInMBorGB) => "Liberar ${sizeInMBorGB}"; - static String m29(count, formattedSize) => + static String m30(count, formattedSize) => "${Intl.plural(count, one: 'Se puede eliminar del dispositivo para liberar ${formattedSize}', other: 'Se pueden eliminar del dispositivo para liberar ${formattedSize}')}"; - static String m31(count) => + static String m32(count) => "${Intl.plural(count, one: '${count} elemento', other: '${count} elementos')}"; - static String m32(expiryTime) => "El enlace caducará en ${expiryTime}"; + static String m33(expiryTime) => "El enlace caducará en ${expiryTime}"; - static String m33(count, formattedCount) => + static String m34(count, formattedCount) => "${Intl.plural(count, zero: 'no recuerdos', one: '${formattedCount} recuerdo', other: '${formattedCount} recuerdos')}\n"; - static String m34(count) => + static String m35(count) => "${Intl.plural(count, one: 'Mover elemento', other: 'Mover elementos')}"; - static String m35(albumName) => "Movido exitosamente a ${albumName}"; + static String m36(albumName) => "Movido exitosamente a ${albumName}"; - static String m36(passwordStrengthValue) => + static String m37(passwordStrengthValue) => "Seguridad de la contraseña : ${passwordStrengthValue}"; - static String m37(providerName) => + static String m38(providerName) => "Por favor hable con el soporte de ${providerName} si se le cobró"; - static String m39(toEmail) => + static String m40(toEmail) => "Por favor, envíanos un correo electrónico a ${toEmail}"; - static String m40(toEmail) => "Por favor, envíe los registros a ${toEmail}"; + static String m41(toEmail) => "Por favor, envíe los registros a ${toEmail}"; - static String m41(storeName) => "Califícanos en ${storeName}"; + static String m42(storeName) => "Califícanos en ${storeName}"; - static String m42(storageInGB) => + static String m43(storageInGB) => "3. Ambos obtienen ${storageInGB} GB* gratis"; - static String m43(userEmail) => + static String m44(userEmail) => "${userEmail} será eliminado de este álbum compartido\n\nCualquier foto añadida por ellos también será eliminada del álbum"; - static String m44(endDate) => "Se renueva el ${endDate}"; + static String m45(endDate) => "Se renueva el ${endDate}"; - static String m46(count) => "${count} seleccionados"; + static String m47(count) => "${count} seleccionados"; - static String m47(count, yourCount) => + static String m48(count, yourCount) => "${count} seleccionados (${yourCount} tuyos)"; - static String m48(verificationID) => + static String m49(verificationID) => "Aquí está mi ID de verificación: ${verificationID} para ente.io."; - static String m49(verificationID) => + static String m50(verificationID) => "Hola, ¿puedes confirmar que esta es tu ID de verificación ente.io: ${verificationID}?"; - static String m50(referralCode, referralStorageInGB) => + static String m51(referralCode, referralStorageInGB) => "ente código de referencia: ${referralCode} \n\nAplicarlo en Ajustes → General → Referencias para obtener ${referralStorageInGB} GB gratis después de registrarse en un plan de pago\n\nhttps://ente.io"; - static String m51(numberOfPeople) => + static String m52(numberOfPeople) => "${Intl.plural(numberOfPeople, zero: 'Compartir con personas específicas', one: 'Compartido con 1 persona', other: 'Compartido con ${numberOfPeople} personas')}"; - static String m52(emailIDs) => "Compartido con ${emailIDs}"; - - static String m53(fileType) => - "Este ${fileType} se eliminará de tu dispositivo."; + static String m53(emailIDs) => "Compartido con ${emailIDs}"; static String m54(fileType) => + "Este ${fileType} se eliminará de tu dispositivo."; + + static String m55(fileType) => "Este ${fileType} está tanto en ente como en tu dispositivo."; - static String m55(fileType) => "Este ${fileType} se eliminará de ente."; + static String m56(fileType) => "Este ${fileType} se eliminará de ente."; - static String m56(storageAmountInGB) => "${storageAmountInGB} GB"; + static String m57(storageAmountInGB) => "${storageAmountInGB} GB"; - static String m57( + static String m58( usedAmount, usedStorageUnit, totalAmount, totalStorageUnit) => "${usedAmount} ${usedStorageUnit} de ${totalAmount} ${totalStorageUnit} usados"; - static String m58(id) => + static String m59(id) => "Su ${id} ya está vinculado a otra cuenta ente.\nSi desea utilizar su ${id} con esta cuenta, póngase en contacto con nuestro servicio de asistencia\'\'"; - static String m59(endDate) => "Tu suscripción se cancelará el ${endDate}"; + static String m60(endDate) => "Tu suscripción se cancelará el ${endDate}"; - static String m60(completed, total) => + static String m61(completed, total) => "${completed}/${total} recuerdos conservados"; - static String m61(storageAmountInGB) => + static String m62(storageAmountInGB) => "También obtienen ${storageAmountInGB} GB"; - static String m62(email) => "Este es el ID de verificación de ${email}"; + static String m63(email) => "Este es el ID de verificación de ${email}"; - static String m65(email) => "Verificar ${email}"; + static String m66(email) => "Verificar ${email}"; - static String m66(email) => + static String m67(email) => "Hemos enviado un correo a ${email}"; - static String m67(count) => + static String m68(count) => "${Intl.plural(count, one: '${count} hace un año', other: '${count} hace años')}"; - static String m68(storageSaved) => "¡Has liberado ${storageSaved} con éxito!"; + static String m69(storageSaved) => "¡Has liberado ${storageSaved} con éxito!"; final messages = _notInlinedMessages(_notInlinedMessages); static Map _notInlinedMessages(_) => { @@ -335,10 +332,10 @@ class MessageLookup extends MessageLookupByLibrary { "canOnlyRemoveFilesOwnedByYou": MessageLookupByLibrary.simpleMessage( "Sólo puede eliminar archivos de tu propiedad"), "cancel": MessageLookupByLibrary.simpleMessage("Cancelar"), - "cancelOtherSubscription": m8, + "cancelOtherSubscription": m9, "cancelSubscription": MessageLookupByLibrary.simpleMessage("Cancelar suscripción"), - "cannotAddMorePhotosAfterBecomingViewer": m9, + "cannotAddMorePhotosAfterBecomingViewer": m10, "centerPoint": MessageLookupByLibrary.simpleMessage("Punto central"), "changeEmail": MessageLookupByLibrary.simpleMessage("Cambiar correo electrónico"), @@ -359,7 +356,7 @@ class MessageLookup extends MessageLookupByLibrary { "Reclamar almacenamiento gratis"), "claimMore": MessageLookupByLibrary.simpleMessage("¡Reclama más!"), "claimed": MessageLookupByLibrary.simpleMessage("Reclamado"), - "claimedStorageSoFar": m10, + "claimedStorageSoFar": m11, "clearCaches": MessageLookupByLibrary.simpleMessage("Limpiar caché"), "click": MessageLookupByLibrary.simpleMessage("• Click"), "clickOnTheOverflowMenu": MessageLookupByLibrary.simpleMessage( @@ -379,7 +376,7 @@ class MessageLookup extends MessageLookupByLibrary { "Crea un enlace para que la gente pueda añadir y ver fotos en tu álbum compartido sin necesidad de la aplicación ente o una cuenta. Genial para recolectar fotos de eventos."), "collaborativeLink": MessageLookupByLibrary.simpleMessage("Enlace colaborativo"), - "collaborativeLinkCreatedFor": m11, + "collaborativeLinkCreatedFor": m12, "collaborator": MessageLookupByLibrary.simpleMessage("Colaborador"), "collaboratorsCanAddPhotosAndVideosToTheSharedAlbum": MessageLookupByLibrary.simpleMessage( @@ -404,10 +401,10 @@ class MessageLookup extends MessageLookupByLibrary { "Confirmar clave de recuperación"), "confirmYourRecoveryKey": MessageLookupByLibrary.simpleMessage( "Confirme su clave de recuperación"), - "contactFamilyAdmin": m12, + "contactFamilyAdmin": m13, "contactSupport": MessageLookupByLibrary.simpleMessage("Contactar con soporte"), - "contactToManageSubscription": m13, + "contactToManageSubscription": m14, "contacts": MessageLookupByLibrary.simpleMessage("Contacts"), "continueLabel": MessageLookupByLibrary.simpleMessage("Continuar"), "continueOnFreeTrial": MessageLookupByLibrary.simpleMessage( @@ -482,7 +479,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Borrar la ubicación"), "deletePhotos": MessageLookupByLibrary.simpleMessage("Borrar las fotos"), - "deleteProgress": m15, + "deleteProgress": m17, "deleteReason1": MessageLookupByLibrary.simpleMessage( "Falta una característica clave que necesito"), "deleteReason2": MessageLookupByLibrary.simpleMessage( @@ -514,7 +511,7 @@ class MessageLookup extends MessageLookupByLibrary { "Los espectadores todavía pueden tomar capturas de pantalla o guardar una copia de sus fotos usando herramientas externas"), "disableDownloadWarningTitle": MessageLookupByLibrary.simpleMessage("Por favor tenga en cuenta"), - "disableLinkMessage": m16, + "disableLinkMessage": m18, "disableTwofactor": MessageLookupByLibrary.simpleMessage("Deshabilitar dos factores"), "disablingTwofactorAuthentication": @@ -535,8 +532,8 @@ class MessageLookup extends MessageLookupByLibrary { "downloadFailed": MessageLookupByLibrary.simpleMessage("Descarga fallida"), "downloading": MessageLookupByLibrary.simpleMessage("Descargando..."), - "dropSupportEmail": m17, - "duplicateFileCountWithStorageSaved": m18, + "dropSupportEmail": m19, + "duplicateFileCountWithStorageSaved": m20, "edit": MessageLookupByLibrary.simpleMessage("Editar"), "editLocation": MessageLookupByLibrary.simpleMessage("Edit location"), "editLocationTagTitle": @@ -548,8 +545,8 @@ class MessageLookup extends MessageLookupByLibrary { "Edits to location will only be seen within Ente"), "eligible": MessageLookupByLibrary.simpleMessage("elegible"), "email": MessageLookupByLibrary.simpleMessage("Correo electrónico"), - "emailChangedTo": m20, - "emailNoEnteAccount": m21, + "emailChangedTo": m22, + "emailNoEnteAccount": m23, "emailYourLogs": MessageLookupByLibrary.simpleMessage( "Envíe sus registros por correo electrónico"), "empty": MessageLookupByLibrary.simpleMessage("Vaciar"), @@ -642,8 +639,8 @@ class MessageLookup extends MessageLookupByLibrary { "fileSavedToGallery": MessageLookupByLibrary.simpleMessage( "Archivo guardado en la galería"), "fileTypes": MessageLookupByLibrary.simpleMessage("File types"), - "filesBackedUpFromDevice": m22, - "filesBackedUpInAlbum": m23, + "filesBackedUpFromDevice": m24, + "filesBackedUpInAlbum": m25, "filesDeleted": MessageLookupByLibrary.simpleMessage("Archivos eliminados"), "flip": MessageLookupByLibrary.simpleMessage("Voltear"), @@ -654,18 +651,17 @@ class MessageLookup extends MessageLookupByLibrary { "foundFaces": MessageLookupByLibrary.simpleMessage("Found faces"), "freeStorageClaimed": MessageLookupByLibrary.simpleMessage( "Almacenamiento gratuito reclamado"), - "freeStorageOnReferralSuccess": m24, - "freeStorageSpace": m25, + "freeStorageOnReferralSuccess": m26, "freeStorageUsable": MessageLookupByLibrary.simpleMessage( "Almacenamiento libre disponible"), "freeTrial": MessageLookupByLibrary.simpleMessage("Prueba gratuita"), - "freeTrialValidTill": m26, - "freeUpAccessPostDelete": m27, - "freeUpAmount": m28, + "freeTrialValidTill": m27, + "freeUpAccessPostDelete": m28, + "freeUpAmount": m29, "freeUpDeviceSpace": MessageLookupByLibrary.simpleMessage( "Liberar espacio del dispositivo"), "freeUpSpace": MessageLookupByLibrary.simpleMessage("Liberar espacio"), - "freeUpSpaceSaving": m29, + "freeUpSpaceSaving": m30, "galleryMemoryLimitInfo": MessageLookupByLibrary.simpleMessage( "Hasta 1000 memorias mostradas en la galería"), "general": MessageLookupByLibrary.simpleMessage("General"), @@ -713,7 +709,7 @@ class MessageLookup extends MessageLookupByLibrary { "itLooksLikeSomethingWentWrongPleaseRetryAfterSome": MessageLookupByLibrary.simpleMessage( "Parece que algo salió mal. Por favor, vuelve a intentarlo después de algún tiempo. Si el error persiste, ponte en contacto con nuestro equipo de soporte."), - "itemCount": m31, + "itemCount": m32, "itemsShowTheNumberOfDaysRemainingBeforePermanentDeletion": MessageLookupByLibrary.simpleMessage( "Los artículos muestran el número de días restantes antes de ser borrados permanente"), @@ -742,7 +738,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Límite del dispositivo"), "linkEnabled": MessageLookupByLibrary.simpleMessage("Habilitado"), "linkExpired": MessageLookupByLibrary.simpleMessage("Vencido"), - "linkExpiresOn": m32, + "linkExpiresOn": m33, "linkExpiry": MessageLookupByLibrary.simpleMessage("Enlace vence"), "linkHasExpired": MessageLookupByLibrary.simpleMessage("El enlace ha caducado"), @@ -811,7 +807,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Administrar tu suscripción"), "mastodon": MessageLookupByLibrary.simpleMessage("Mastodon"), "matrix": MessageLookupByLibrary.simpleMessage("Matrix"), - "memoryCount": m33, + "memoryCount": m34, "merchandise": MessageLookupByLibrary.simpleMessage("Mercancías"), "mobileWebDesktop": MessageLookupByLibrary.simpleMessage("Celular, Web, Computadora"), @@ -820,11 +816,11 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage( "Modify your query, or try searching for"), "monthly": MessageLookupByLibrary.simpleMessage("Mensual"), - "moveItem": m34, + "moveItem": m35, "moveToAlbum": MessageLookupByLibrary.simpleMessage("Mover al álbum"), "moveToHiddenAlbum": MessageLookupByLibrary.simpleMessage("Move to hidden album"), - "movedSuccessfullyTo": m35, + "movedSuccessfullyTo": m36, "movedToTrash": MessageLookupByLibrary.simpleMessage("Movido a la papelera"), "movingFilesToAlbum": MessageLookupByLibrary.simpleMessage( @@ -874,13 +870,13 @@ class MessageLookup extends MessageLookupByLibrary { "Contraseña cambiada correctamente"), "passwordLock": MessageLookupByLibrary.simpleMessage("Bloqueo por contraseña"), - "passwordStrength": m36, + "passwordStrength": m37, "passwordWarning": MessageLookupByLibrary.simpleMessage( "No almacenamos esta contraseña, así que si la olvidas, no podemos descifrar tus datos"), "paymentDetails": MessageLookupByLibrary.simpleMessage("Detalles de pago"), "paymentFailed": MessageLookupByLibrary.simpleMessage("Pago fallido"), - "paymentFailedTalkToProvider": m37, + "paymentFailedTalkToProvider": m38, "pendingSync": MessageLookupByLibrary.simpleMessage("Sincronización pendiente"), "peopleUsingYourCode": @@ -907,12 +903,12 @@ class MessageLookup extends MessageLookupByLibrary { "pleaseContactSupportIfTheProblemPersists": MessageLookupByLibrary.simpleMessage( "Por favor contacte a soporte técnico si el problema persiste"), - "pleaseEmailUsAt": m39, + "pleaseEmailUsAt": m40, "pleaseGrantPermissions": MessageLookupByLibrary.simpleMessage("Por favor, concede permiso"), "pleaseLoginAgain": MessageLookupByLibrary.simpleMessage( "Por favor, vuelva a iniciar sesión"), - "pleaseSendTheLogsTo": m40, + "pleaseSendTheLogsTo": m41, "pleaseTryAgain": MessageLookupByLibrary.simpleMessage( "Por favor, inténtalo nuevamente"), "pleaseVerifyTheCodeYouHaveEntered": @@ -946,7 +942,7 @@ class MessageLookup extends MessageLookupByLibrary { "rateTheApp": MessageLookupByLibrary.simpleMessage("Evalúa la aplicación"), "rateUs": MessageLookupByLibrary.simpleMessage("Califícanos"), - "rateUsOnStore": m41, + "rateUsOnStore": m42, "recover": MessageLookupByLibrary.simpleMessage("Recuperar"), "recoverAccount": MessageLookupByLibrary.simpleMessage("Recuperar cuenta"), @@ -978,7 +974,7 @@ class MessageLookup extends MessageLookupByLibrary { "1. Dale este código a tus amigos"), "referralStep2": MessageLookupByLibrary.simpleMessage( "2. Se inscriben a un plan pagado"), - "referralStep3": m42, + "referralStep3": m43, "referrals": MessageLookupByLibrary.simpleMessage("Referidos"), "referralsAreCurrentlyPaused": MessageLookupByLibrary.simpleMessage( "Las referencias están actualmente en pausa"), @@ -1003,7 +999,7 @@ class MessageLookup extends MessageLookupByLibrary { "removeLink": MessageLookupByLibrary.simpleMessage("Eliminar enlace"), "removeParticipant": MessageLookupByLibrary.simpleMessage("Quitar participante"), - "removeParticipantBody": m43, + "removeParticipantBody": m44, "removePersonLabel": MessageLookupByLibrary.simpleMessage("Remove person label"), "removePublicLink": @@ -1019,7 +1015,7 @@ class MessageLookup extends MessageLookupByLibrary { "renameFile": MessageLookupByLibrary.simpleMessage("Renombrar archivo"), "renewSubscription": MessageLookupByLibrary.simpleMessage("Renovar suscripción"), - "renewsOn": m44, + "renewsOn": m45, "reportABug": MessageLookupByLibrary.simpleMessage("Reportar un error"), "reportBug": MessageLookupByLibrary.simpleMessage("Reportar error"), "resendEmail": @@ -1082,8 +1078,8 @@ class MessageLookup extends MessageLookupByLibrary { "selectedItemsWillBeDeletedFromAllAlbumsAndMoved": MessageLookupByLibrary.simpleMessage( "Los archivos seleccionados serán eliminados de todos los álbumes y movidos a la papelera."), - "selectedPhotos": m46, - "selectedPhotosWithYours": m47, + "selectedPhotos": m47, + "selectedPhotosWithYours": m48, "send": MessageLookupByLibrary.simpleMessage("Enviar"), "sendEmail": MessageLookupByLibrary.simpleMessage("Enviar correo electrónico"), @@ -1108,32 +1104,32 @@ class MessageLookup extends MessageLookupByLibrary { "shareAnAlbumNow": MessageLookupByLibrary.simpleMessage("Compartir un álbum ahora"), "shareLink": MessageLookupByLibrary.simpleMessage("Compartir enlace"), - "shareMyVerificationID": m48, + "shareMyVerificationID": m49, "shareOnlyWithThePeopleYouWant": MessageLookupByLibrary.simpleMessage( "Comparte sólo con la gente que quieres"), - "shareTextConfirmOthersVerificationID": m49, + "shareTextConfirmOthersVerificationID": m50, "shareTextRecommendUsingEnte": MessageLookupByLibrary.simpleMessage( "Descarga ente para que podamos compartir fácilmente fotos y videos en su calidad original\n\nhttps://ente.io"), - "shareTextReferralCode": m50, + "shareTextReferralCode": m51, "shareWithNonenteUsers": MessageLookupByLibrary.simpleMessage( "Compartir con usuarios no ente"), - "shareWithPeopleSectionTitle": m51, + "shareWithPeopleSectionTitle": m52, "shareYourFirstAlbum": MessageLookupByLibrary.simpleMessage("Comparte tu primer álbum"), "sharedAlbumSectionDescription": MessageLookupByLibrary.simpleMessage( "Crear álbumes compartidos y colaborativos con otros usuarios ente, incluyendo usuarios en planes gratuitos."), "sharedByMe": MessageLookupByLibrary.simpleMessage("Compartido por mí"), - "sharedWith": m52, + "sharedWith": m53, "sharedWithMe": MessageLookupByLibrary.simpleMessage("Compartido conmigo"), "sharing": MessageLookupByLibrary.simpleMessage("Compartiendo..."), "signUpTerms": MessageLookupByLibrary.simpleMessage( "Estoy de acuerdo con los términos del servicio y la política de privacidad"), - "singleFileDeleteFromDevice": m53, + "singleFileDeleteFromDevice": m54, "singleFileDeleteHighlight": MessageLookupByLibrary.simpleMessage( "Se borrará de todos los álbumes."), - "singleFileInBothLocalAndRemote": m54, - "singleFileInRemoteOnly": m55, + "singleFileInBothLocalAndRemote": m55, + "singleFileInRemoteOnly": m56, "skip": MessageLookupByLibrary.simpleMessage("Omitir"), "social": MessageLookupByLibrary.simpleMessage("Social"), "someItemsAreInBothEnteAndYourDevice": @@ -1168,13 +1164,13 @@ class MessageLookup extends MessageLookupByLibrary { "storage": MessageLookupByLibrary.simpleMessage("Almacenamiento"), "storageBreakupFamily": MessageLookupByLibrary.simpleMessage("Familia"), "storageBreakupYou": MessageLookupByLibrary.simpleMessage("Usted"), - "storageInGB": m56, + "storageInGB": m57, "storageLimitExceeded": MessageLookupByLibrary.simpleMessage("Límite de datos excedido"), - "storageUsageInfo": m57, + "storageUsageInfo": m58, "strongStrength": MessageLookupByLibrary.simpleMessage("Segura"), - "subAlreadyLinkedErrMessage": m58, - "subWillBeCancelledOn": m59, + "subAlreadyLinkedErrMessage": m59, + "subWillBeCancelledOn": m60, "subscribe": MessageLookupByLibrary.simpleMessage("Suscribirse"), "subscribeToEnableSharing": MessageLookupByLibrary.simpleMessage( "Parece que su suscripción ha caducado. Por favor, suscríbase para habilitar el compartir."), @@ -1187,7 +1183,7 @@ class MessageLookup extends MessageLookupByLibrary { "suggestFeatures": MessageLookupByLibrary.simpleMessage("Sugerir una característica"), "support": MessageLookupByLibrary.simpleMessage("Soporte"), - "syncProgress": m60, + "syncProgress": m61, "syncStopped": MessageLookupByLibrary.simpleMessage("Sincronización detenida"), "syncing": MessageLookupByLibrary.simpleMessage("Sincronizando..."), @@ -1215,7 +1211,7 @@ class MessageLookup extends MessageLookupByLibrary { "theseItemsWillBeDeletedFromYourDevice": MessageLookupByLibrary.simpleMessage( "Estos elementos se eliminarán de tu dispositivo."), - "theyAlsoGetXGb": m61, + "theyAlsoGetXGb": m62, "theyWillBeDeletedFromAllAlbums": MessageLookupByLibrary.simpleMessage( "Se borrarán de todos los álbumes."), "thisActionCannotBeUndone": MessageLookupByLibrary.simpleMessage( @@ -1231,7 +1227,7 @@ class MessageLookup extends MessageLookupByLibrary { "Este correo electrónico ya está en uso"), "thisImageHasNoExifData": MessageLookupByLibrary.simpleMessage( "Esta imagen no tiene datos exif"), - "thisIsPersonVerificationId": m62, + "thisIsPersonVerificationId": m63, "thisIsYourVerificationId": MessageLookupByLibrary.simpleMessage( "Esta es tu ID de verificación"), "thisWillLogYouOutOfTheFollowingDevice": @@ -1301,7 +1297,7 @@ class MessageLookup extends MessageLookupByLibrary { "verify": MessageLookupByLibrary.simpleMessage("Verificar"), "verifyEmail": MessageLookupByLibrary.simpleMessage( "Verificar correo electrónico"), - "verifyEmailID": m65, + "verifyEmailID": m66, "verifyIDLabel": MessageLookupByLibrary.simpleMessage("Verificar"), "verifyPassword": MessageLookupByLibrary.simpleMessage("Verificar contraseña"), @@ -1324,12 +1320,12 @@ class MessageLookup extends MessageLookupByLibrary { "weDontSupportEditingPhotosAndAlbumsThatYouDont": MessageLookupByLibrary.simpleMessage( "No admitimos la edición de fotos y álbunes que aún no son tuyos"), - "weHaveSendEmailTo": m66, + "weHaveSendEmailTo": m67, "weakStrength": MessageLookupByLibrary.simpleMessage("Poco segura"), "welcomeBack": MessageLookupByLibrary.simpleMessage("¡Bienvenido de nuevo!"), "yearly": MessageLookupByLibrary.simpleMessage("Anualmente"), - "yearsAgo": m67, + "yearsAgo": m68, "yes": MessageLookupByLibrary.simpleMessage("Sí"), "yesCancel": MessageLookupByLibrary.simpleMessage("Sí, cancelar"), "yesConvertToViewer": @@ -1359,7 +1355,7 @@ class MessageLookup extends MessageLookupByLibrary { "No puedes compartir contigo mismo"), "youDontHaveAnyArchivedItems": MessageLookupByLibrary.simpleMessage( "No tienes nada de elementos archivados."), - "youHaveSuccessfullyFreedUp": m68, + "youHaveSuccessfullyFreedUp": m69, "yourAccountHasBeenDeleted": MessageLookupByLibrary.simpleMessage("Su cuenta ha sido eliminada"), "yourMap": MessageLookupByLibrary.simpleMessage("Your map"), diff --git a/mobile/lib/generated/intl/messages_fr.dart b/mobile/lib/generated/intl/messages_fr.dart index 5b3183b891..7cae363308 100644 --- a/mobile/lib/generated/intl/messages_fr.dart +++ b/mobile/lib/generated/intl/messages_fr.dart @@ -38,13 +38,16 @@ class MessageLookup extends MessageLookupByLibrary { static String m7(versionValue) => "Version : ${versionValue}"; - static String m8(paymentProvider) => + static String m8(freeAmount, storageUnit) => + "${freeAmount} ${storageUnit} libre"; + + static String m9(paymentProvider) => "Veuillez d\'abord annuler votre abonnement existant de ${paymentProvider}"; - static String m9(user) => + static String m10(user) => "${user} ne pourra pas ajouter plus de photos à cet album\n\nIl pourrait toujours supprimer les photos existantes ajoutées par eux"; - static String m10(isFamilyMember, storageAmountInGb) => + static String m11(isFamilyMember, storageAmountInGb) => "${Intl.select(isFamilyMember, { 'true': 'Votre famille a demandé ${storageAmountInGb} GB jusqu\'à présent', @@ -54,158 +57,155 @@ class MessageLookup extends MessageLookupByLibrary { 'Vous avez réclamé ${storageAmountInGb} GB jusqu\'à présent!', })}"; - static String m11(albumName) => "Lien collaboratif créé pour ${albumName}"; + static String m12(albumName) => "Lien collaboratif créé pour ${albumName}"; - static String m12(familyAdminEmail) => + static String m13(familyAdminEmail) => "Veuillez contacter ${familyAdminEmail} pour gérer votre abonnement"; - static String m13(provider) => + static String m14(provider) => "Veuillez nous contacter à support@ente.io pour gérer votre abonnement ${provider}."; - static String m14(count) => + static String m16(count) => "${Intl.plural(count, one: 'Supprimer le fichier', other: 'Supprimer ${count} fichiers')}"; - static String m15(currentlyDeleting, totalCount) => + static String m17(currentlyDeleting, totalCount) => "Suppression de ${currentlyDeleting} / ${totalCount}"; - static String m16(albumName) => + static String m18(albumName) => "Cela supprimera le lien public pour accéder à \"${albumName}\"."; - static String m17(supportEmail) => + static String m19(supportEmail) => "Veuillez envoyer un e-mail à ${supportEmail} depuis votre adresse enregistrée"; - static String m18(count, storageSaved) => + static String m20(count, storageSaved) => "Vous avez nettoyé ${Intl.plural(count, one: '${count} fichier dupliqué', other: '${count} fichiers dupliqués')}, sauvegarde (${storageSaved}!)"; - static String m19(count, formattedSize) => + static String m21(count, formattedSize) => "${count} fichiers, ${formattedSize} chacun"; - static String m20(newEmail) => "L\'e-mail a été changé en ${newEmail}"; + static String m22(newEmail) => "L\'e-mail a été changé en ${newEmail}"; - static String m21(email) => + static String m23(email) => "${email} n\'a pas de compte ente.\n\nEnvoyez une invitation pour partager des photos."; - static String m22(count, formattedNumber) => + static String m24(count, formattedNumber) => "${Intl.plural(count, one: '1 fichier sur cet appareil a été sauvegardé en toute sécurité', other: '${formattedNumber} fichiers sur cet appareil ont été sauvegardés en toute sécurité')}"; - static String m23(count, formattedNumber) => + static String m25(count, formattedNumber) => "${Intl.plural(count, one: '1 fichier dans cet album a été sauvegardé en toute sécurité', other: '${formattedNumber} fichiers dans cet album ont été sauvegardés en toute sécurité')}"; - static String m24(storageAmountInGB) => + static String m26(storageAmountInGB) => "${storageAmountInGB} Go chaque fois que quelqu\'un s\'inscrit à une offre payante et applique votre code"; - static String m25(freeAmount, storageUnit) => - "${freeAmount} ${storageUnit} libre"; + static String m27(endDate) => "Essai gratuit valide jusqu’au ${endDate}"; - static String m26(endDate) => "Essai gratuit valide jusqu’au ${endDate}"; - - static String m27(count) => + static String m28(count) => "Vous pouvez toujours ${Intl.plural(count, one: 'y', other: 'y')} accéder sur ente tant que vous avez un abonnement actif"; - static String m28(sizeInMBorGB) => "Libérer ${sizeInMBorGB}"; + static String m29(sizeInMBorGB) => "Libérer ${sizeInMBorGB}"; - static String m29(count, formattedSize) => + static String m30(count, formattedSize) => "${Intl.plural(count, one: 'Peut être supprimé de l\'appareil pour libérer ${formattedSize}', other: 'Peuvent être supprimés de l\'appareil pour libérer ${formattedSize}')}"; - static String m31(count) => + static String m32(count) => "${Intl.plural(count, one: '${count} objet', other: '${count} objets')}"; - static String m32(expiryTime) => "Le lien expirera le ${expiryTime}"; + static String m33(expiryTime) => "Le lien expirera le ${expiryTime}"; - static String m33(count, formattedCount) => + static String m34(count, formattedCount) => "${Intl.plural(count, one: '${formattedCount} mémoire', other: '${formattedCount} souvenirs')}"; - static String m34(count) => + static String m35(count) => "${Intl.plural(count, one: 'Déplacez l\'objet', other: 'Déplacez des objets')}"; - static String m35(albumName) => "Déplacé avec succès vers ${albumName}"; + static String m36(albumName) => "Déplacé avec succès vers ${albumName}"; - static String m36(passwordStrengthValue) => + static String m37(passwordStrengthValue) => "Sécurité du mot de passe : ${passwordStrengthValue}"; - static String m37(providerName) => + static String m38(providerName) => "Veuillez contacter le support ${providerName} si vous avez été facturé"; - static String m38(endDate) => + static String m39(endDate) => "Essai gratuit valable jusqu\'à ${endDate}.\nVous pouvez choisir un plan payant par la suite."; - static String m39(toEmail) => "Merci de nous envoyer un e-mail à ${toEmail}"; + static String m40(toEmail) => "Merci de nous envoyer un e-mail à ${toEmail}"; - static String m40(toEmail) => "Envoyez les logs à ${toEmail}"; + static String m41(toEmail) => "Envoyez les logs à ${toEmail}"; - static String m41(storeName) => "Notez-nous sur ${storeName}"; + static String m42(storeName) => "Notez-nous sur ${storeName}"; - static String m42(storageInGB) => + static String m43(storageInGB) => "3. Vous recevez tous les deux ${storageInGB} GB* gratuits"; - static String m43(userEmail) => + static String m44(userEmail) => "${userEmail} sera retiré de cet album partagé\n\nToutes les photos ajoutées par eux seront également retirées de l\'album"; - static String m44(endDate) => "Renouvellement le ${endDate}"; + static String m45(endDate) => "Renouvellement le ${endDate}"; - static String m45(count) => + static String m46(count) => "${Intl.plural(count, one: '${count} résultat trouvé', other: '${count} résultats trouvés')}"; - static String m46(count) => "${count} sélectionné(s)"; + static String m47(count) => "${count} sélectionné(s)"; - static String m47(count, yourCount) => + static String m48(count, yourCount) => "${count} sélectionné(s) (${yourCount} à vous)"; - static String m48(verificationID) => + static String m49(verificationID) => "Voici mon ID de vérification : ${verificationID} pour ente.io."; - static String m49(verificationID) => + static String m50(verificationID) => "Hé, pouvez-vous confirmer qu\'il s\'agit de votre ID de vérification ente.io : ${verificationID}"; - static String m50(referralCode, referralStorageInGB) => + static String m51(referralCode, referralStorageInGB) => "code de parrainage ente : ${referralCode} \n\nAppliquez le dans Paramètres → Général → Références pour obtenir ${referralStorageInGB} Go gratuitement après votre inscription à un plan payant\n\nhttps://ente.io"; - static String m51(numberOfPeople) => + static String m52(numberOfPeople) => "${Intl.plural(numberOfPeople, zero: 'Partagez avec des personnes spécifiques', one: 'Partagé avec 1 personne', other: 'Partagé avec ${numberOfPeople} des gens')}"; - static String m52(emailIDs) => "Partagé avec ${emailIDs}"; - - static String m53(fileType) => - "Elle ${fileType} sera supprimée de votre appareil."; + static String m53(emailIDs) => "Partagé avec ${emailIDs}"; static String m54(fileType) => + "Elle ${fileType} sera supprimée de votre appareil."; + + static String m55(fileType) => "Cette ${fileType} est à la fois sur ente et sur votre appareil."; - static String m55(fileType) => "Ce ${fileType} sera supprimé de ente."; + static String m56(fileType) => "Ce ${fileType} sera supprimé de ente."; - static String m56(storageAmountInGB) => "${storageAmountInGB} Go"; + static String m57(storageAmountInGB) => "${storageAmountInGB} Go"; - static String m57( + static String m58( usedAmount, usedStorageUnit, totalAmount, totalStorageUnit) => "${usedAmount} ${usedStorageUnit} sur ${totalAmount} ${totalStorageUnit} utilisé"; - static String m58(id) => + static String m59(id) => "Votre ${id} est déjà lié à un autre compte ente.\nSi vous souhaitez utiliser votre ${id} avec ce compte, veuillez contacter notre support"; - static String m59(endDate) => "Votre abonnement sera annulé le ${endDate}"; + static String m60(endDate) => "Votre abonnement sera annulé le ${endDate}"; - static String m60(completed, total) => + static String m61(completed, total) => "${completed}/${total} souvenirs préservés"; - static String m61(storageAmountInGB) => + static String m62(storageAmountInGB) => "Ils obtiennent aussi ${storageAmountInGB} Go"; - static String m62(email) => "Ceci est l\'ID de vérification de ${email}"; + static String m63(email) => "Ceci est l\'ID de vérification de ${email}"; - static String m63(count) => + static String m64(count) => "${Intl.plural(count, zero: '0 jour', one: '1 jour', other: '${count} jours')}"; - static String m64(endDate) => "Valable jusqu\'au ${endDate}"; + static String m65(endDate) => "Valable jusqu\'au ${endDate}"; - static String m65(email) => "Vérifier ${email}"; + static String m66(email) => "Vérifier ${email}"; - static String m66(email) => + static String m67(email) => "Nous avons envoyé un e-mail à ${email}"; - static String m67(count) => + static String m68(count) => "${Intl.plural(count, one: 'il y a ${count} an', other: 'il y a ${count} ans')}"; - static String m68(storageSaved) => + static String m69(storageSaved) => "Vous avez libéré ${storageSaved} avec succès !"; final messages = _notInlinedMessages(_notInlinedMessages); @@ -362,6 +362,7 @@ class MessageLookup extends MessageLookupByLibrary { "authenticationSuccessful": MessageLookupByLibrary.simpleMessage("Authentification réussie!"), "available": MessageLookupByLibrary.simpleMessage("Disponible"), + "availableStorageSpace": m8, "backedUpFolders": MessageLookupByLibrary.simpleMessage("Dossiers sauvegardés"), "backup": MessageLookupByLibrary.simpleMessage("Sauvegarde"), @@ -388,10 +389,10 @@ class MessageLookup extends MessageLookupByLibrary { "canOnlyRemoveFilesOwnedByYou": MessageLookupByLibrary.simpleMessage( "Vous ne pouvez supprimer que les fichiers que vous possédez"), "cancel": MessageLookupByLibrary.simpleMessage("Annuler"), - "cancelOtherSubscription": m8, + "cancelOtherSubscription": m9, "cancelSubscription": MessageLookupByLibrary.simpleMessage("Annuler l\'abonnement"), - "cannotAddMorePhotosAfterBecomingViewer": m9, + "cannotAddMorePhotosAfterBecomingViewer": m10, "cannotDeleteSharedFiles": MessageLookupByLibrary.simpleMessage( "Les fichiers partagés ne peuvent pas être supprimés"), "centerPoint": MessageLookupByLibrary.simpleMessage("Point central"), @@ -414,7 +415,7 @@ class MessageLookup extends MessageLookupByLibrary { "Réclamer le stockage gratuit"), "claimMore": MessageLookupByLibrary.simpleMessage("Réclamez plus !"), "claimed": MessageLookupByLibrary.simpleMessage("Réclamée"), - "claimedStorageSoFar": m10, + "claimedStorageSoFar": m11, "clearCaches": MessageLookupByLibrary.simpleMessage("Nettoyer le cache"), "click": MessageLookupByLibrary.simpleMessage("• Click"), @@ -437,7 +438,7 @@ class MessageLookup extends MessageLookupByLibrary { "Créez un lien pour permettre aux gens d\'ajouter et de voir des photos dans votre album partagé sans avoir besoin d\'une application ente ou d\'un compte. Idéal pour collecter des photos d\'événement."), "collaborativeLink": MessageLookupByLibrary.simpleMessage("Lien collaboratif"), - "collaborativeLinkCreatedFor": m11, + "collaborativeLinkCreatedFor": m12, "collaborator": MessageLookupByLibrary.simpleMessage("Collaborateur"), "collaboratorsCanAddPhotosAndVideosToTheSharedAlbum": MessageLookupByLibrary.simpleMessage( @@ -465,10 +466,10 @@ class MessageLookup extends MessageLookupByLibrary { "Confirmer la clé de récupération"), "confirmYourRecoveryKey": MessageLookupByLibrary.simpleMessage( "Confirmer la clé de récupération"), - "contactFamilyAdmin": m12, + "contactFamilyAdmin": m13, "contactSupport": MessageLookupByLibrary.simpleMessage("Contacter l\'assistance"), - "contactToManageSubscription": m13, + "contactToManageSubscription": m14, "contacts": MessageLookupByLibrary.simpleMessage("Contacts"), "contents": MessageLookupByLibrary.simpleMessage("Contenus"), "continueLabel": MessageLookupByLibrary.simpleMessage("Continuer"), @@ -551,12 +552,12 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Supprimer de l\'appareil"), "deleteFromEnte": MessageLookupByLibrary.simpleMessage("Supprimer de ente"), - "deleteItemCount": m14, + "deleteItemCount": m16, "deleteLocation": MessageLookupByLibrary.simpleMessage("Supprimer la localisation"), "deletePhotos": MessageLookupByLibrary.simpleMessage("Supprimer des photos"), - "deleteProgress": m15, + "deleteProgress": m17, "deleteReason1": MessageLookupByLibrary.simpleMessage( "Il manque une fonction clé dont j\'ai besoin"), "deleteReason2": MessageLookupByLibrary.simpleMessage( @@ -588,7 +589,7 @@ class MessageLookup extends MessageLookupByLibrary { "Les téléspectateurs peuvent toujours prendre des captures d\'écran ou enregistrer une copie de vos photos en utilisant des outils externes"), "disableDownloadWarningTitle": MessageLookupByLibrary.simpleMessage("Veuillez remarquer"), - "disableLinkMessage": m16, + "disableLinkMessage": m18, "disableTwofactor": MessageLookupByLibrary.simpleMessage( "Désactiver la double-authentification"), "disablingTwofactorAuthentication": @@ -609,9 +610,9 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Échec du téléchargement"), "downloading": MessageLookupByLibrary.simpleMessage("Téléchargement en cours..."), - "dropSupportEmail": m17, - "duplicateFileCountWithStorageSaved": m18, - "duplicateItemsGroup": m19, + "dropSupportEmail": m19, + "duplicateFileCountWithStorageSaved": m20, + "duplicateItemsGroup": m21, "edit": MessageLookupByLibrary.simpleMessage("Éditer"), "editLocation": MessageLookupByLibrary.simpleMessage("Edit location"), "editLocationTagTitle": @@ -623,8 +624,8 @@ class MessageLookup extends MessageLookupByLibrary { "Edits to location will only be seen within Ente"), "eligible": MessageLookupByLibrary.simpleMessage("éligible"), "email": MessageLookupByLibrary.simpleMessage("E-mail"), - "emailChangedTo": m20, - "emailNoEnteAccount": m21, + "emailChangedTo": m22, + "emailNoEnteAccount": m23, "emailVerificationToggle": MessageLookupByLibrary.simpleMessage( "Vérification de l\'adresse e-mail"), "emailYourLogs": @@ -727,8 +728,8 @@ class MessageLookup extends MessageLookupByLibrary { "fileTypes": MessageLookupByLibrary.simpleMessage("Types de fichiers"), "fileTypesAndNames": MessageLookupByLibrary.simpleMessage("Types et noms de fichiers"), - "filesBackedUpFromDevice": m22, - "filesBackedUpInAlbum": m23, + "filesBackedUpFromDevice": m24, + "filesBackedUpInAlbum": m25, "filesDeleted": MessageLookupByLibrary.simpleMessage("Fichiers supprimés"), "flip": MessageLookupByLibrary.simpleMessage("Retourner"), @@ -739,19 +740,18 @@ class MessageLookup extends MessageLookupByLibrary { "foundFaces": MessageLookupByLibrary.simpleMessage("Found faces"), "freeStorageClaimed": MessageLookupByLibrary.simpleMessage("Stockage gratuit réclamé"), - "freeStorageOnReferralSuccess": m24, - "freeStorageSpace": m25, + "freeStorageOnReferralSuccess": m26, "freeStorageUsable": MessageLookupByLibrary.simpleMessage("Stockage gratuit utilisable"), "freeTrial": MessageLookupByLibrary.simpleMessage("Essai gratuit"), - "freeTrialValidTill": m26, - "freeUpAccessPostDelete": m27, - "freeUpAmount": m28, + "freeTrialValidTill": m27, + "freeUpAccessPostDelete": m28, + "freeUpAmount": m29, "freeUpDeviceSpace": MessageLookupByLibrary.simpleMessage( "Libérer de l\'espace sur l\'appareil"), "freeUpSpace": MessageLookupByLibrary.simpleMessage("Libérer de l\'espace"), - "freeUpSpaceSaving": m29, + "freeUpSpaceSaving": m30, "galleryMemoryLimitInfo": MessageLookupByLibrary.simpleMessage( "Jusqu\'à 1000 souvenirs affichés dans la galerie"), "general": MessageLookupByLibrary.simpleMessage("Général"), @@ -820,7 +820,7 @@ class MessageLookup extends MessageLookupByLibrary { "itLooksLikeSomethingWentWrongPleaseRetryAfterSome": MessageLookupByLibrary.simpleMessage( "Il semble qu\'une erreur s\'est produite. Veuillez réessayer après un certain temps. Si l\'erreur persiste, veuillez contacter notre équipe d\'assistance."), - "itemCount": m31, + "itemCount": m32, "itemsShowTheNumberOfDaysRemainingBeforePermanentDeletion": MessageLookupByLibrary.simpleMessage( "Les éléments montrent le nombre de jours restants avant la suppression définitive"), @@ -849,7 +849,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Limite d\'appareil"), "linkEnabled": MessageLookupByLibrary.simpleMessage("Activé"), "linkExpired": MessageLookupByLibrary.simpleMessage("Expiré"), - "linkExpiresOn": m32, + "linkExpiresOn": m33, "linkExpiry": MessageLookupByLibrary.simpleMessage("Expiration du lien"), "linkHasExpired": @@ -918,7 +918,7 @@ class MessageLookup extends MessageLookupByLibrary { "maps": MessageLookupByLibrary.simpleMessage("Cartes"), "mastodon": MessageLookupByLibrary.simpleMessage("Mastodon"), "matrix": MessageLookupByLibrary.simpleMessage("Matrix"), - "memoryCount": m33, + "memoryCount": m34, "merchandise": MessageLookupByLibrary.simpleMessage("Marchandise"), "mobileWebDesktop": MessageLookupByLibrary.simpleMessage("Mobile, Web, Ordinateur"), @@ -929,12 +929,12 @@ class MessageLookup extends MessageLookupByLibrary { "Modifiez votre requête, ou essayez de rechercher"), "moments": MessageLookupByLibrary.simpleMessage("Souvenirs"), "monthly": MessageLookupByLibrary.simpleMessage("Mensuel"), - "moveItem": m34, + "moveItem": m35, "moveToAlbum": MessageLookupByLibrary.simpleMessage("Déplacer vers l\'album"), "moveToHiddenAlbum": MessageLookupByLibrary.simpleMessage( "Déplacer vers un album masqué"), - "movedSuccessfullyTo": m35, + "movedSuccessfullyTo": m36, "movedToTrash": MessageLookupByLibrary.simpleMessage("Déplacé dans la corbeille"), "movingFilesToAlbum": MessageLookupByLibrary.simpleMessage( @@ -998,14 +998,14 @@ class MessageLookup extends MessageLookupByLibrary { "Le mot de passe a été modifié"), "passwordLock": MessageLookupByLibrary.simpleMessage("Mot de passe verrou"), - "passwordStrength": m36, + "passwordStrength": m37, "passwordWarning": MessageLookupByLibrary.simpleMessage( "Nous ne stockons pas ce mot de passe, donc si vous l\'oubliez, nous ne pouvons pas déchiffrer vos données"), "paymentDetails": MessageLookupByLibrary.simpleMessage("Détails de paiement"), "paymentFailed": MessageLookupByLibrary.simpleMessage("Échec du paiement"), - "paymentFailedTalkToProvider": m37, + "paymentFailedTalkToProvider": m38, "pendingSync": MessageLookupByLibrary.simpleMessage("Synchronisation en attente"), "peopleUsingYourCode": MessageLookupByLibrary.simpleMessage( @@ -1028,7 +1028,7 @@ class MessageLookup extends MessageLookupByLibrary { "pickCenterPoint": MessageLookupByLibrary.simpleMessage( "Sélectionner le point central"), "pinAlbum": MessageLookupByLibrary.simpleMessage("Épingler l\'album"), - "playStoreFreeTrialValidTill": m38, + "playStoreFreeTrialValidTill": m39, "playstoreSubscription": MessageLookupByLibrary.simpleMessage("Abonnement au PlayStore"), "pleaseContactSupportAndWeWillBeHappyToHelp": @@ -1037,12 +1037,12 @@ class MessageLookup extends MessageLookupByLibrary { "pleaseContactSupportIfTheProblemPersists": MessageLookupByLibrary.simpleMessage( "Merci de contacter l\'assistance si cette erreur persiste"), - "pleaseEmailUsAt": m39, + "pleaseEmailUsAt": m40, "pleaseGrantPermissions": MessageLookupByLibrary.simpleMessage( "Veuillez accorder la permission"), "pleaseLoginAgain": MessageLookupByLibrary.simpleMessage("Veuillez vous reconnecter"), - "pleaseSendTheLogsTo": m40, + "pleaseSendTheLogsTo": m41, "pleaseTryAgain": MessageLookupByLibrary.simpleMessage("Veuillez réessayer"), "pleaseVerifyTheCodeYouHaveEntered": @@ -1078,7 +1078,7 @@ class MessageLookup extends MessageLookupByLibrary { "rateTheApp": MessageLookupByLibrary.simpleMessage("Évaluer l\'application"), "rateUs": MessageLookupByLibrary.simpleMessage("Évaluez-nous"), - "rateUsOnStore": m41, + "rateUsOnStore": m42, "recover": MessageLookupByLibrary.simpleMessage("Récupérer"), "recoverAccount": MessageLookupByLibrary.simpleMessage("Récupérer un compte"), @@ -1109,7 +1109,7 @@ class MessageLookup extends MessageLookupByLibrary { "1. Donnez ce code à vos amis"), "referralStep2": MessageLookupByLibrary.simpleMessage( "2. Ils s\'inscrivent à une offre payante"), - "referralStep3": m42, + "referralStep3": m43, "referrals": MessageLookupByLibrary.simpleMessage("Parrainages"), "referralsAreCurrentlyPaused": MessageLookupByLibrary.simpleMessage( "Les recommandations sont actuellement en pause"), @@ -1135,7 +1135,7 @@ class MessageLookup extends MessageLookupByLibrary { "removeLink": MessageLookupByLibrary.simpleMessage("Supprimer le lien"), "removeParticipant": MessageLookupByLibrary.simpleMessage("Supprimer le participant"), - "removeParticipantBody": m43, + "removeParticipantBody": m44, "removePersonLabel": MessageLookupByLibrary.simpleMessage("Remove person label"), "removePublicLink": @@ -1153,7 +1153,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Renommer le fichier"), "renewSubscription": MessageLookupByLibrary.simpleMessage("Renouveler l’abonnement"), - "renewsOn": m44, + "renewsOn": m45, "reportABug": MessageLookupByLibrary.simpleMessage("Signaler un bug"), "reportBug": MessageLookupByLibrary.simpleMessage("Signaler un bug"), "resendEmail": @@ -1219,7 +1219,7 @@ class MessageLookup extends MessageLookupByLibrary { "Grouper les photos qui sont prises dans un certain angle d\'une photo"), "searchPeopleEmptySection": MessageLookupByLibrary.simpleMessage( "Invitez des gens, et vous verrez ici toutes les photos qu\'ils partagent"), - "searchResultCount": m45, + "searchResultCount": m46, "security": MessageLookupByLibrary.simpleMessage("Sécurité"), "selectALocation": MessageLookupByLibrary.simpleMessage("Select a location"), @@ -1248,8 +1248,8 @@ class MessageLookup extends MessageLookupByLibrary { "selectedItemsWillBeDeletedFromAllAlbumsAndMoved": MessageLookupByLibrary.simpleMessage( "Les éléments sélectionnés seront supprimés de tous les albums et déplacés dans la corbeille."), - "selectedPhotos": m46, - "selectedPhotosWithYours": m47, + "selectedPhotos": m47, + "selectedPhotosWithYours": m48, "send": MessageLookupByLibrary.simpleMessage("Envoyer"), "sendEmail": MessageLookupByLibrary.simpleMessage("Envoyer un e-mail"), "sendInvite": @@ -1275,16 +1275,16 @@ class MessageLookup extends MessageLookupByLibrary { "shareAnAlbumNow": MessageLookupByLibrary.simpleMessage( "Partagez un album maintenant"), "shareLink": MessageLookupByLibrary.simpleMessage("Partager le lien"), - "shareMyVerificationID": m48, + "shareMyVerificationID": m49, "shareOnlyWithThePeopleYouWant": MessageLookupByLibrary.simpleMessage( "Partager uniquement avec les personnes que vous voulez"), - "shareTextConfirmOthersVerificationID": m49, + "shareTextConfirmOthersVerificationID": m50, "shareTextRecommendUsingEnte": MessageLookupByLibrary.simpleMessage( "Téléchargez ente pour que nous puissions facilement partager des photos et des vidéos de qualité originale\n\nhttps://ente.io"), - "shareTextReferralCode": m50, + "shareTextReferralCode": m51, "shareWithNonenteUsers": MessageLookupByLibrary.simpleMessage( "Partager avec des utilisateurs non-ente"), - "shareWithPeopleSectionTitle": m51, + "shareWithPeopleSectionTitle": m52, "shareYourFirstAlbum": MessageLookupByLibrary.simpleMessage( "Partagez votre premier album"), "sharedAlbumSectionDescription": MessageLookupByLibrary.simpleMessage( @@ -1295,7 +1295,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Nouvelles photos partagées"), "sharedPhotoNotificationsExplanation": MessageLookupByLibrary.simpleMessage( "Recevoir des notifications quand quelqu\'un ajoute une photo à un album partagé dont vous faites partie"), - "sharedWith": m52, + "sharedWith": m53, "sharedWithMe": MessageLookupByLibrary.simpleMessage("Partagés avec moi"), "sharedWithYou": @@ -1305,11 +1305,11 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Montrer les souvenirs"), "signUpTerms": MessageLookupByLibrary.simpleMessage( "J\'accepte les conditions d\'utilisation et la politique de confidentialité"), - "singleFileDeleteFromDevice": m53, + "singleFileDeleteFromDevice": m54, "singleFileDeleteHighlight": MessageLookupByLibrary.simpleMessage( "Elle sera supprimée de tous les albums."), - "singleFileInBothLocalAndRemote": m54, - "singleFileInRemoteOnly": m55, + "singleFileInBothLocalAndRemote": m55, + "singleFileInRemoteOnly": m56, "skip": MessageLookupByLibrary.simpleMessage("Ignorer"), "social": MessageLookupByLibrary.simpleMessage("Réseaux Sociaux"), "someItemsAreInBothEnteAndYourDevice": @@ -1349,14 +1349,14 @@ class MessageLookup extends MessageLookupByLibrary { "storage": MessageLookupByLibrary.simpleMessage("Stockage"), "storageBreakupFamily": MessageLookupByLibrary.simpleMessage("Famille"), "storageBreakupYou": MessageLookupByLibrary.simpleMessage("Vous"), - "storageInGB": m56, + "storageInGB": m57, "storageLimitExceeded": MessageLookupByLibrary.simpleMessage("Limite de stockage atteinte"), - "storageUsageInfo": m57, + "storageUsageInfo": m58, "strongStrength": MessageLookupByLibrary.simpleMessage("Securité forte"), - "subAlreadyLinkedErrMessage": m58, - "subWillBeCancelledOn": m59, + "subAlreadyLinkedErrMessage": m59, + "subWillBeCancelledOn": m60, "subscribe": MessageLookupByLibrary.simpleMessage("S\'abonner"), "subscribeToEnableSharing": MessageLookupByLibrary.simpleMessage( "Il semble que votre abonnement ait expiré. Veuillez vous abonner pour activer le partage."), @@ -1373,7 +1373,7 @@ class MessageLookup extends MessageLookupByLibrary { "suggestFeatures": MessageLookupByLibrary.simpleMessage( "Suggérer des fonctionnalités"), "support": MessageLookupByLibrary.simpleMessage("Support"), - "syncProgress": m60, + "syncProgress": m61, "syncStopped": MessageLookupByLibrary.simpleMessage("Synchronisation arrêtée ?"), "syncing": MessageLookupByLibrary.simpleMessage( @@ -1402,7 +1402,7 @@ class MessageLookup extends MessageLookupByLibrary { "theseItemsWillBeDeletedFromYourDevice": MessageLookupByLibrary.simpleMessage( "Ces éléments seront supprimés de votre appareil."), - "theyAlsoGetXGb": m61, + "theyAlsoGetXGb": m62, "theyWillBeDeletedFromAllAlbums": MessageLookupByLibrary.simpleMessage( "Ils seront supprimés de tous les albums."), "thisActionCannotBeUndone": MessageLookupByLibrary.simpleMessage( @@ -1418,7 +1418,7 @@ class MessageLookup extends MessageLookupByLibrary { "Cette adresse mail est déjà utilisé"), "thisImageHasNoExifData": MessageLookupByLibrary.simpleMessage( "Cette image n\'a pas de données exif"), - "thisIsPersonVerificationId": m62, + "thisIsPersonVerificationId": m63, "thisIsYourVerificationId": MessageLookupByLibrary.simpleMessage( "Ceci est votre ID de vérification"), "thisWillLogYouOutOfTheFollowingDevice": @@ -1434,7 +1434,7 @@ class MessageLookup extends MessageLookupByLibrary { "total": MessageLookupByLibrary.simpleMessage("total"), "totalSize": MessageLookupByLibrary.simpleMessage("Taille totale"), "trash": MessageLookupByLibrary.simpleMessage("Corbeille"), - "trashDaysLeft": m63, + "trashDaysLeft": m64, "tryAgain": MessageLookupByLibrary.simpleMessage("Réessayer"), "turnOnBackupForAutoUpload": MessageLookupByLibrary.simpleMessage( "Activez la sauvegarde pour télécharger automatiquement les fichiers ajoutés à ce dossier de l\'appareil sur ente."), @@ -1492,7 +1492,7 @@ class MessageLookup extends MessageLookupByLibrary { "useSelectedPhoto": MessageLookupByLibrary.simpleMessage( "Utiliser la photo sélectionnée"), "usedSpace": MessageLookupByLibrary.simpleMessage("Mémoire utilisée"), - "validTill": m64, + "validTill": m65, "verificationFailedPleaseTryAgain": MessageLookupByLibrary.simpleMessage( "La vérification a échouée, veuillez réessayer"), @@ -1501,7 +1501,7 @@ class MessageLookup extends MessageLookupByLibrary { "verify": MessageLookupByLibrary.simpleMessage("Vérifier"), "verifyEmail": MessageLookupByLibrary.simpleMessage("Vérifier l\'email"), - "verifyEmailID": m65, + "verifyEmailID": m66, "verifyIDLabel": MessageLookupByLibrary.simpleMessage("Vérifier"), "verifyPassword": MessageLookupByLibrary.simpleMessage("Vérifier le mot de passe"), @@ -1530,11 +1530,11 @@ class MessageLookup extends MessageLookupByLibrary { "weDontSupportEditingPhotosAndAlbumsThatYouDont": MessageLookupByLibrary.simpleMessage( "Nous ne prenons pas en charge l\'édition des photos et des albums que vous ne possédez pas encore"), - "weHaveSendEmailTo": m66, + "weHaveSendEmailTo": m67, "weakStrength": MessageLookupByLibrary.simpleMessage("Securité Faible"), "welcomeBack": MessageLookupByLibrary.simpleMessage("Bienvenue !"), "yearly": MessageLookupByLibrary.simpleMessage("Annuel"), - "yearsAgo": m67, + "yearsAgo": m68, "yes": MessageLookupByLibrary.simpleMessage("Oui"), "yesCancel": MessageLookupByLibrary.simpleMessage("Oui, annuler"), "yesConvertToViewer": MessageLookupByLibrary.simpleMessage( @@ -1565,7 +1565,7 @@ class MessageLookup extends MessageLookupByLibrary { "Vous ne pouvez pas partager avec vous-même"), "youDontHaveAnyArchivedItems": MessageLookupByLibrary.simpleMessage( "Vous n\'avez aucun élément archivé."), - "youHaveSuccessfullyFreedUp": m68, + "youHaveSuccessfullyFreedUp": m69, "yourAccountHasBeenDeleted": MessageLookupByLibrary.simpleMessage("Votre compte a été supprimé"), "yourMap": MessageLookupByLibrary.simpleMessage("Votre carte"), diff --git a/mobile/lib/generated/intl/messages_it.dart b/mobile/lib/generated/intl/messages_it.dart index 7d8e0916ad..2ca1cb0531 100644 --- a/mobile/lib/generated/intl/messages_it.dart +++ b/mobile/lib/generated/intl/messages_it.dart @@ -38,13 +38,16 @@ class MessageLookup extends MessageLookupByLibrary { static String m7(versionValue) => "Versione: ${versionValue}"; - static String m8(paymentProvider) => + static String m8(freeAmount, storageUnit) => + "${freeAmount} ${storageUnit} liberi"; + + static String m9(paymentProvider) => "Annulla prima il tuo abbonamento esistente da ${paymentProvider}"; - static String m9(user) => + static String m10(user) => "${user} non sarà più in grado di aggiungere altre foto a questo album\n\nSarà ancora in grado di rimuovere le foto esistenti aggiunte da lui o lei"; - static String m10(isFamilyMember, storageAmountInGb) => + static String m11(isFamilyMember, storageAmountInGb) => "${Intl.select(isFamilyMember, { 'true': 'Il tuo piano famiglia ha già richiesto ${storageAmountInGb} GB finora', @@ -52,155 +55,152 @@ class MessageLookup extends MessageLookupByLibrary { 'other': 'Hai già richiesto ${storageAmountInGb} GB finora!', })}"; - static String m11(albumName) => "Link collaborativo creato per ${albumName}"; + static String m12(albumName) => "Link collaborativo creato per ${albumName}"; - static String m12(familyAdminEmail) => + static String m13(familyAdminEmail) => "Contatta ${familyAdminEmail} per gestire il tuo abbonamento"; - static String m13(provider) => + static String m14(provider) => "Scrivi all\'indirizzo support@ente.io per gestire il tuo abbonamento ${provider}."; - static String m14(count) => + static String m16(count) => "${Intl.plural(count, one: 'Elimina ${count} elemento', other: 'Elimina ${count} elementi')}"; - static String m15(currentlyDeleting, totalCount) => + static String m17(currentlyDeleting, totalCount) => "Eliminazione di ${currentlyDeleting} / ${totalCount}"; - static String m16(albumName) => + static String m18(albumName) => "Questo rimuoverà il link pubblico per accedere a \"${albumName}\"."; - static String m17(supportEmail) => + static String m19(supportEmail) => "Per favore invia un\'email a ${supportEmail} dall\'indirizzo email con cui ti sei registrato"; - static String m18(count, storageSaved) => + static String m20(count, storageSaved) => "Hai ripulito ${Intl.plural(count, one: '${count} doppione', other: '${count} doppioni')}, salvando (${storageSaved}!)"; - static String m19(count, formattedSize) => + static String m21(count, formattedSize) => "${count} file, ${formattedSize} l\'uno"; - static String m20(newEmail) => "Email cambiata in ${newEmail}"; + static String m22(newEmail) => "Email cambiata in ${newEmail}"; - static String m21(email) => + static String m23(email) => "${email} non ha un account su ente.\n\nInvia un invito per condividere foto."; - static String m22(count, formattedNumber) => + static String m24(count, formattedNumber) => "${Intl.plural(count, one: '1 file', other: '${formattedNumber} file')} di quest\'album sono stati salvati in modo sicuro"; - static String m23(count, formattedNumber) => + static String m25(count, formattedNumber) => "${Intl.plural(count, one: '1 file', other: '${formattedNumber} file')} di quest\'album sono stati salvati in modo sicuro"; - static String m24(storageAmountInGB) => + static String m26(storageAmountInGB) => "${storageAmountInGB} GB ogni volta che qualcuno si iscrive a un piano a pagamento e applica il tuo codice"; - static String m25(freeAmount, storageUnit) => - "${freeAmount} ${storageUnit} liberi"; + static String m27(endDate) => "La prova gratuita termina il ${endDate}"; - static String m26(endDate) => "La prova gratuita termina il ${endDate}"; - - static String m27(count) => + static String m28(count) => "Puoi ancora accedere a ${Intl.plural(count, one: '', other: 'loro')} su ente finché hai un abbonamento attivo"; - static String m28(sizeInMBorGB) => "Libera ${sizeInMBorGB}"; + static String m29(sizeInMBorGB) => "Libera ${sizeInMBorGB}"; - static String m29(count, formattedSize) => + static String m30(count, formattedSize) => "${Intl.plural(count, one: 'Può essere cancellata per liberare ${formattedSize}', other: 'Possono essere cancellati per liberare ${formattedSize}')}"; - static String m31(count) => + static String m32(count) => "${Intl.plural(count, one: '${count} elemento', other: '${count} elementi')}"; - static String m32(expiryTime) => "Il link scadrà il ${expiryTime}"; + static String m33(expiryTime) => "Il link scadrà il ${expiryTime}"; - static String m33(count, formattedCount) => + static String m34(count, formattedCount) => "${Intl.plural(count, one: '${formattedCount} ricordo', other: '${formattedCount} ricordi')}"; - static String m34(count) => + static String m35(count) => "${Intl.plural(count, one: 'Sposta elemento', other: 'Sposta elementi')}"; - static String m35(albumName) => "Spostato con successo su ${albumName}"; + static String m36(albumName) => "Spostato con successo su ${albumName}"; - static String m36(passwordStrengthValue) => + static String m37(passwordStrengthValue) => "Sicurezza password: ${passwordStrengthValue}"; - static String m37(providerName) => + static String m38(providerName) => "Si prega di parlare con il supporto di ${providerName} se ti è stato addebitato qualcosa"; - static String m38(endDate) => + static String m39(endDate) => "Prova gratuita valida fino al ${endDate}.\nPuoi scegliere un piano a pagamento in seguito."; - static String m39(toEmail) => "Per favore invia un\'email a ${toEmail}"; + static String m40(toEmail) => "Per favore invia un\'email a ${toEmail}"; - static String m40(toEmail) => "Invia i log a \n${toEmail}"; + static String m41(toEmail) => "Invia i log a \n${toEmail}"; - static String m41(storeName) => "Valutaci su ${storeName}"; + static String m42(storeName) => "Valutaci su ${storeName}"; - static String m42(storageInGB) => + static String m43(storageInGB) => "3. Ottenete entrambi ${storageInGB} GB* gratis"; - static String m43(userEmail) => + static String m44(userEmail) => "${userEmail} verrà rimosso da questo album condiviso\n\nQualsiasi foto aggiunta dall\'utente verrà rimossa dall\'album"; - static String m44(endDate) => "Si rinnova il ${endDate}"; + static String m45(endDate) => "Si rinnova il ${endDate}"; - static String m46(count) => "${count} selezionati"; + static String m47(count) => "${count} selezionati"; - static String m47(count, yourCount) => + static String m48(count, yourCount) => "${count} selezionato (${yourCount} tuoi)"; - static String m48(verificationID) => + static String m49(verificationID) => "Ecco il mio ID di verifica: ${verificationID} per ente.io."; - static String m49(verificationID) => + static String m50(verificationID) => "Hey, puoi confermare che questo è il tuo ID di verifica: ${verificationID} su ente.io"; - static String m50(referralCode, referralStorageInGB) => + static String m51(referralCode, referralStorageInGB) => "ente referral code: ${referralCode} \n\nApplicalo in Impostazioni → Generale → Referral per ottenere ${referralStorageInGB} GB gratis dopo la registrazione di un piano a pagamento\n\nhttps://ente.io"; - static String m51(numberOfPeople) => + static String m52(numberOfPeople) => "${Intl.plural(numberOfPeople, zero: 'Condividi con persone specifiche', one: 'Condividi con una persona', other: 'Condividi con ${numberOfPeople} persone')}"; - static String m52(emailIDs) => "Condiviso con ${emailIDs}"; - - static String m53(fileType) => - "Questo ${fileType} verrà eliminato dal tuo dispositivo."; + static String m53(emailIDs) => "Condiviso con ${emailIDs}"; static String m54(fileType) => + "Questo ${fileType} verrà eliminato dal tuo dispositivo."; + + static String m55(fileType) => "Questo ${fileType} è sia su ente che sul tuo dispositivo."; - static String m55(fileType) => "Questo ${fileType} verrà eliminato su ente."; + static String m56(fileType) => "Questo ${fileType} verrà eliminato su ente."; - static String m56(storageAmountInGB) => "${storageAmountInGB} GB"; + static String m57(storageAmountInGB) => "${storageAmountInGB} GB"; - static String m57( + static String m58( usedAmount, usedStorageUnit, totalAmount, totalStorageUnit) => "${usedAmount} ${usedStorageUnit} di ${totalAmount} ${totalStorageUnit} utilizzati"; - static String m58(id) => + static String m59(id) => "Il tuo ${id} è già collegato ad un altro account ente.\nSe desideri utilizzare il tuo ${id} con questo account, contatta il nostro supporto\'\'"; - static String m59(endDate) => "L\'abbonamento verrà cancellato il ${endDate}"; + static String m60(endDate) => "L\'abbonamento verrà cancellato il ${endDate}"; - static String m60(completed, total) => + static String m61(completed, total) => "${completed}/${total} ricordi conservati"; - static String m61(storageAmountInGB) => + static String m62(storageAmountInGB) => "Anche loro riceveranno ${storageAmountInGB} GB"; - static String m62(email) => "Questo è l\'ID di verifica di ${email}"; + static String m63(email) => "Questo è l\'ID di verifica di ${email}"; - static String m63(count) => + static String m64(count) => "${Intl.plural(count, zero: '', one: '1 giorno', other: '${count} giorni')}"; - static String m64(endDate) => "Valido fino al ${endDate}"; + static String m65(endDate) => "Valido fino al ${endDate}"; - static String m65(email) => "Verifica ${email}"; + static String m66(email) => "Verifica ${email}"; - static String m66(email) => + static String m67(email) => "Abbiamo inviato una mail a ${email}"; - static String m67(count) => + static String m68(count) => "${Intl.plural(count, one: '${count} anno fa', other: '${count} anni fa')}"; - static String m68(storageSaved) => + static String m69(storageSaved) => "Hai liberato con successo ${storageSaved}!"; final messages = _notInlinedMessages(_notInlinedMessages); @@ -353,6 +353,7 @@ class MessageLookup extends MessageLookupByLibrary { "authenticationSuccessful": MessageLookupByLibrary.simpleMessage("Autenticazione riuscita!"), "available": MessageLookupByLibrary.simpleMessage("Disponibile"), + "availableStorageSpace": m8, "backedUpFolders": MessageLookupByLibrary.simpleMessage("Cartelle salvate"), "backup": MessageLookupByLibrary.simpleMessage("Backup"), @@ -375,10 +376,10 @@ class MessageLookup extends MessageLookupByLibrary { "canOnlyRemoveFilesOwnedByYou": MessageLookupByLibrary.simpleMessage( "Puoi rimuovere solo i file di tua proprietà"), "cancel": MessageLookupByLibrary.simpleMessage("Annulla"), - "cancelOtherSubscription": m8, + "cancelOtherSubscription": m9, "cancelSubscription": MessageLookupByLibrary.simpleMessage("Annulla abbonamento"), - "cannotAddMorePhotosAfterBecomingViewer": m9, + "cannotAddMorePhotosAfterBecomingViewer": m10, "cannotDeleteSharedFiles": MessageLookupByLibrary.simpleMessage( "Impossibile eliminare i file condivisi"), "centerPoint": MessageLookupByLibrary.simpleMessage("Punto centrale"), @@ -401,7 +402,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Richiedi spazio gratuito"), "claimMore": MessageLookupByLibrary.simpleMessage("Richiedine di più!"), "claimed": MessageLookupByLibrary.simpleMessage("Riscattato"), - "claimedStorageSoFar": m10, + "claimedStorageSoFar": m11, "clearCaches": MessageLookupByLibrary.simpleMessage("Svuota cache"), "click": MessageLookupByLibrary.simpleMessage("• Clic"), "clickOnTheOverflowMenu": @@ -423,7 +424,7 @@ class MessageLookup extends MessageLookupByLibrary { "Crea un link per consentire alle persone di aggiungere e visualizzare foto nel tuo album condiviso senza bisogno di un\'applicazione o di un account ente. Ottimo per raccogliere foto di un evento."), "collaborativeLink": MessageLookupByLibrary.simpleMessage("Link collaborativo"), - "collaborativeLinkCreatedFor": m11, + "collaborativeLinkCreatedFor": m12, "collaborator": MessageLookupByLibrary.simpleMessage("Collaboratore"), "collaboratorsCanAddPhotosAndVideosToTheSharedAlbum": MessageLookupByLibrary.simpleMessage( @@ -451,10 +452,10 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Conferma chiave di recupero"), "confirmYourRecoveryKey": MessageLookupByLibrary.simpleMessage( "Conferma la tua chiave di recupero"), - "contactFamilyAdmin": m12, + "contactFamilyAdmin": m13, "contactSupport": MessageLookupByLibrary.simpleMessage("Contatta il supporto"), - "contactToManageSubscription": m13, + "contactToManageSubscription": m14, "contacts": MessageLookupByLibrary.simpleMessage("Contacts"), "continueLabel": MessageLookupByLibrary.simpleMessage("Continua"), "continueOnFreeTrial": @@ -532,11 +533,11 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Elimina dal dispositivo"), "deleteFromEnte": MessageLookupByLibrary.simpleMessage("Elimina da ente"), - "deleteItemCount": m14, + "deleteItemCount": m16, "deleteLocation": MessageLookupByLibrary.simpleMessage("Elimina posizione"), "deletePhotos": MessageLookupByLibrary.simpleMessage("Elimina foto"), - "deleteProgress": m15, + "deleteProgress": m17, "deleteReason1": MessageLookupByLibrary.simpleMessage( "Manca una caratteristica chiave di cui ho bisogno"), "deleteReason2": MessageLookupByLibrary.simpleMessage( @@ -568,7 +569,7 @@ class MessageLookup extends MessageLookupByLibrary { "I visualizzatori possono scattare screenshot o salvare una copia delle foto utilizzando strumenti esterni"), "disableDownloadWarningTitle": MessageLookupByLibrary.simpleMessage("Nota bene"), - "disableLinkMessage": m16, + "disableLinkMessage": m18, "disableTwofactor": MessageLookupByLibrary.simpleMessage( "Disabilita autenticazione a due fattori"), "disablingTwofactorAuthentication": @@ -589,9 +590,9 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Scaricamento fallito"), "downloading": MessageLookupByLibrary.simpleMessage("Scaricamento in corso..."), - "dropSupportEmail": m17, - "duplicateFileCountWithStorageSaved": m18, - "duplicateItemsGroup": m19, + "dropSupportEmail": m19, + "duplicateFileCountWithStorageSaved": m20, + "duplicateItemsGroup": m21, "edit": MessageLookupByLibrary.simpleMessage("Modifica"), "editLocation": MessageLookupByLibrary.simpleMessage("Edit location"), "editLocationTagTitle": @@ -602,8 +603,8 @@ class MessageLookup extends MessageLookupByLibrary { "Edits to location will only be seen within Ente"), "eligible": MessageLookupByLibrary.simpleMessage("idoneo"), "email": MessageLookupByLibrary.simpleMessage("Email"), - "emailChangedTo": m20, - "emailNoEnteAccount": m21, + "emailChangedTo": m22, + "emailNoEnteAccount": m23, "emailVerificationToggle": MessageLookupByLibrary.simpleMessage("Verifica Email"), "emailYourLogs": MessageLookupByLibrary.simpleMessage( @@ -700,8 +701,8 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Aggiungi descrizione..."), "fileSavedToGallery": MessageLookupByLibrary.simpleMessage("File salvato nella galleria"), - "filesBackedUpFromDevice": m22, - "filesBackedUpInAlbum": m23, + "filesBackedUpFromDevice": m24, + "filesBackedUpInAlbum": m25, "filesDeleted": MessageLookupByLibrary.simpleMessage("File eliminati"), "flip": MessageLookupByLibrary.simpleMessage("Capovolgi"), "forYourMemories": @@ -711,18 +712,17 @@ class MessageLookup extends MessageLookupByLibrary { "foundFaces": MessageLookupByLibrary.simpleMessage("Found faces"), "freeStorageClaimed": MessageLookupByLibrary.simpleMessage("Spazio gratuito richiesto"), - "freeStorageOnReferralSuccess": m24, - "freeStorageSpace": m25, + "freeStorageOnReferralSuccess": m26, "freeStorageUsable": MessageLookupByLibrary.simpleMessage("Spazio libero utilizzabile"), "freeTrial": MessageLookupByLibrary.simpleMessage("Prova gratuita"), - "freeTrialValidTill": m26, - "freeUpAccessPostDelete": m27, - "freeUpAmount": m28, + "freeTrialValidTill": m27, + "freeUpAccessPostDelete": m28, + "freeUpAmount": m29, "freeUpDeviceSpace": MessageLookupByLibrary.simpleMessage("Libera spazio"), "freeUpSpace": MessageLookupByLibrary.simpleMessage("Libera spazio"), - "freeUpSpaceSaving": m29, + "freeUpSpaceSaving": m30, "galleryMemoryLimitInfo": MessageLookupByLibrary.simpleMessage( "Fino a 1000 ricordi mostrati nella galleria"), "general": MessageLookupByLibrary.simpleMessage("Generali"), @@ -789,7 +789,7 @@ class MessageLookup extends MessageLookupByLibrary { "itLooksLikeSomethingWentWrongPleaseRetryAfterSome": MessageLookupByLibrary.simpleMessage( "Sembra che qualcosa sia andato storto. Riprova tra un po\'. Se l\'errore persiste, contatta il nostro team di supporto."), - "itemCount": m31, + "itemCount": m32, "itemsShowTheNumberOfDaysRemainingBeforePermanentDeletion": MessageLookupByLibrary.simpleMessage( "Gli elementi mostrano il numero di giorni rimanenti prima della cancellazione permanente"), @@ -818,7 +818,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Limite dei dispositivi"), "linkEnabled": MessageLookupByLibrary.simpleMessage("Attivato"), "linkExpired": MessageLookupByLibrary.simpleMessage("Scaduto"), - "linkExpiresOn": m32, + "linkExpiresOn": m33, "linkExpiry": MessageLookupByLibrary.simpleMessage("Scadenza del link"), "linkHasExpired": MessageLookupByLibrary.simpleMessage("Il link è scaduto"), @@ -887,7 +887,7 @@ class MessageLookup extends MessageLookupByLibrary { "maps": MessageLookupByLibrary.simpleMessage("Mappe"), "mastodon": MessageLookupByLibrary.simpleMessage("Mastodon"), "matrix": MessageLookupByLibrary.simpleMessage("Matrix"), - "memoryCount": m33, + "memoryCount": m34, "merchandise": MessageLookupByLibrary.simpleMessage("Merchandise"), "mobileWebDesktop": MessageLookupByLibrary.simpleMessage("Mobile, Web, Desktop"), @@ -896,12 +896,12 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage( "Modify your query, or try searching for"), "monthly": MessageLookupByLibrary.simpleMessage("Mensile"), - "moveItem": m34, + "moveItem": m35, "moveToAlbum": MessageLookupByLibrary.simpleMessage("Sposta nell\'album"), "moveToHiddenAlbum": MessageLookupByLibrary.simpleMessage("Sposta in album nascosto"), - "movedSuccessfullyTo": m35, + "movedSuccessfullyTo": m36, "movedToTrash": MessageLookupByLibrary.simpleMessage("Spostato nel cestino"), "movingFilesToAlbum": MessageLookupByLibrary.simpleMessage( @@ -965,14 +965,14 @@ class MessageLookup extends MessageLookupByLibrary { "Password modificata con successo"), "passwordLock": MessageLookupByLibrary.simpleMessage("Blocco con password"), - "passwordStrength": m36, + "passwordStrength": m37, "passwordWarning": MessageLookupByLibrary.simpleMessage( "Noi non memorizziamo la tua password, quindi se te la dimentichi, non possiamo decriptare i tuoi dati"), "paymentDetails": MessageLookupByLibrary.simpleMessage("Dettagli di Pagamento"), "paymentFailed": MessageLookupByLibrary.simpleMessage("Pagamento non riuscito"), - "paymentFailedTalkToProvider": m37, + "paymentFailedTalkToProvider": m38, "pendingSync": MessageLookupByLibrary.simpleMessage("Sincronizzazione in sospeso"), "peopleUsingYourCode": MessageLookupByLibrary.simpleMessage( @@ -992,7 +992,7 @@ class MessageLookup extends MessageLookupByLibrary { "pickCenterPoint": MessageLookupByLibrary.simpleMessage( "Selezionare il punto centrale"), "pinAlbum": MessageLookupByLibrary.simpleMessage("Fissa l\'album"), - "playStoreFreeTrialValidTill": m38, + "playStoreFreeTrialValidTill": m39, "playstoreSubscription": MessageLookupByLibrary.simpleMessage("Abbonamento su PlayStore"), "pleaseContactSupportAndWeWillBeHappyToHelp": @@ -1001,12 +1001,12 @@ class MessageLookup extends MessageLookupByLibrary { "pleaseContactSupportIfTheProblemPersists": MessageLookupByLibrary.simpleMessage( "Riprova. Se il problema persiste, ti invitiamo a contattare l\'assistenza"), - "pleaseEmailUsAt": m39, + "pleaseEmailUsAt": m40, "pleaseGrantPermissions": MessageLookupByLibrary.simpleMessage("Concedi i permessi"), "pleaseLoginAgain": MessageLookupByLibrary.simpleMessage( "Effettua nuovamente l\'accesso"), - "pleaseSendTheLogsTo": m40, + "pleaseSendTheLogsTo": m41, "pleaseTryAgain": MessageLookupByLibrary.simpleMessage("Riprova"), "pleaseVerifyTheCodeYouHaveEntered": MessageLookupByLibrary.simpleMessage( @@ -1040,7 +1040,7 @@ class MessageLookup extends MessageLookupByLibrary { "raiseTicket": MessageLookupByLibrary.simpleMessage("Invia ticket"), "rateTheApp": MessageLookupByLibrary.simpleMessage("Valuta l\'app"), "rateUs": MessageLookupByLibrary.simpleMessage("Lascia una recensione"), - "rateUsOnStore": m41, + "rateUsOnStore": m42, "recover": MessageLookupByLibrary.simpleMessage("Recupera"), "recoverAccount": MessageLookupByLibrary.simpleMessage("Recupera account"), @@ -1072,7 +1072,7 @@ class MessageLookup extends MessageLookupByLibrary { "1. Condividi questo codice con i tuoi amici"), "referralStep2": MessageLookupByLibrary.simpleMessage( "2. Si iscrivono per un piano a pagamento"), - "referralStep3": m42, + "referralStep3": m43, "referrals": MessageLookupByLibrary.simpleMessage("Invita un Amico"), "referralsAreCurrentlyPaused": MessageLookupByLibrary.simpleMessage( "I referral code sono attualmente in pausa"), @@ -1096,7 +1096,7 @@ class MessageLookup extends MessageLookupByLibrary { "removeLink": MessageLookupByLibrary.simpleMessage("Elimina link"), "removeParticipant": MessageLookupByLibrary.simpleMessage("Rimuovi partecipante"), - "removeParticipantBody": m43, + "removeParticipantBody": m44, "removePersonLabel": MessageLookupByLibrary.simpleMessage("Remove person label"), "removePublicLink": @@ -1112,7 +1112,7 @@ class MessageLookup extends MessageLookupByLibrary { "renameFile": MessageLookupByLibrary.simpleMessage("Rinomina file"), "renewSubscription": MessageLookupByLibrary.simpleMessage("Rinnova abbonamento"), - "renewsOn": m44, + "renewsOn": m45, "reportABug": MessageLookupByLibrary.simpleMessage("Segnala un bug"), "reportBug": MessageLookupByLibrary.simpleMessage("Segnala un bug"), "resendEmail": MessageLookupByLibrary.simpleMessage("Rinvia email"), @@ -1178,8 +1178,8 @@ class MessageLookup extends MessageLookupByLibrary { "selectedItemsWillBeDeletedFromAllAlbumsAndMoved": MessageLookupByLibrary.simpleMessage( "Gli elementi selezionati verranno eliminati da tutti gli album e spostati nel cestino."), - "selectedPhotos": m46, - "selectedPhotosWithYours": m47, + "selectedPhotos": m47, + "selectedPhotosWithYours": m48, "send": MessageLookupByLibrary.simpleMessage("Invia"), "sendEmail": MessageLookupByLibrary.simpleMessage("Invia email"), "sendInvite": MessageLookupByLibrary.simpleMessage("Invita"), @@ -1203,16 +1203,16 @@ class MessageLookup extends MessageLookupByLibrary { "shareAnAlbumNow": MessageLookupByLibrary.simpleMessage("Condividi un album"), "shareLink": MessageLookupByLibrary.simpleMessage("Condividi link"), - "shareMyVerificationID": m48, + "shareMyVerificationID": m49, "shareOnlyWithThePeopleYouWant": MessageLookupByLibrary.simpleMessage( "Condividi solo con le persone che vuoi"), - "shareTextConfirmOthersVerificationID": m49, + "shareTextConfirmOthersVerificationID": m50, "shareTextRecommendUsingEnte": MessageLookupByLibrary.simpleMessage( "Scarica ente in modo da poter facilmente condividere foto e video senza perdita di qualità\n\nhttps://ente.io"), - "shareTextReferralCode": m50, + "shareTextReferralCode": m51, "shareWithNonenteUsers": MessageLookupByLibrary.simpleMessage( "Condividi con utenti che non hanno un account ente"), - "shareWithPeopleSectionTitle": m51, + "shareWithPeopleSectionTitle": m52, "shareYourFirstAlbum": MessageLookupByLibrary.simpleMessage( "Condividi il tuo primo album"), "sharedAlbumSectionDescription": MessageLookupByLibrary.simpleMessage( @@ -1223,7 +1223,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Nuove foto condivise"), "sharedPhotoNotificationsExplanation": MessageLookupByLibrary.simpleMessage( "Ricevi notifiche quando qualcuno aggiunge una foto a un album condiviso, di cui fai parte"), - "sharedWith": m52, + "sharedWith": m53, "sharedWithMe": MessageLookupByLibrary.simpleMessage("Condivisi con me"), "sharedWithYou": @@ -1233,11 +1233,11 @@ class MessageLookup extends MessageLookupByLibrary { "showMemories": MessageLookupByLibrary.simpleMessage("Mostra ricordi"), "signUpTerms": MessageLookupByLibrary.simpleMessage( "Accetto i termini di servizio e la politica sulla privacy"), - "singleFileDeleteFromDevice": m53, + "singleFileDeleteFromDevice": m54, "singleFileDeleteHighlight": MessageLookupByLibrary.simpleMessage( "Verrà eliminato da tutti gli album."), - "singleFileInBothLocalAndRemote": m54, - "singleFileInRemoteOnly": m55, + "singleFileInBothLocalAndRemote": m55, + "singleFileInRemoteOnly": m56, "skip": MessageLookupByLibrary.simpleMessage("Salta"), "social": MessageLookupByLibrary.simpleMessage("Social"), "someItemsAreInBothEnteAndYourDevice": @@ -1278,13 +1278,13 @@ class MessageLookup extends MessageLookupByLibrary { "storageBreakupFamily": MessageLookupByLibrary.simpleMessage("Famiglia"), "storageBreakupYou": MessageLookupByLibrary.simpleMessage("Tu"), - "storageInGB": m56, + "storageInGB": m57, "storageLimitExceeded": MessageLookupByLibrary.simpleMessage( "Limite d\'archiviazione superato"), - "storageUsageInfo": m57, + "storageUsageInfo": m58, "strongStrength": MessageLookupByLibrary.simpleMessage("Forte"), - "subAlreadyLinkedErrMessage": m58, - "subWillBeCancelledOn": m59, + "subAlreadyLinkedErrMessage": m59, + "subWillBeCancelledOn": m60, "subscribe": MessageLookupByLibrary.simpleMessage("Iscriviti"), "subscribeToEnableSharing": MessageLookupByLibrary.simpleMessage( "Sembra che il tuo abbonamento sia scaduto. Iscriviti per abilitare la condivisione."), @@ -1301,7 +1301,7 @@ class MessageLookup extends MessageLookupByLibrary { "suggestFeatures": MessageLookupByLibrary.simpleMessage("Suggerisci una funzionalità"), "support": MessageLookupByLibrary.simpleMessage("Assistenza"), - "syncProgress": m60, + "syncProgress": m61, "syncStopped": MessageLookupByLibrary.simpleMessage("Sincronizzazione interrotta"), "syncing": MessageLookupByLibrary.simpleMessage( @@ -1330,7 +1330,7 @@ class MessageLookup extends MessageLookupByLibrary { "theseItemsWillBeDeletedFromYourDevice": MessageLookupByLibrary.simpleMessage( "Questi file verranno eliminati dal tuo dispositivo."), - "theyAlsoGetXGb": m61, + "theyAlsoGetXGb": m62, "theyWillBeDeletedFromAllAlbums": MessageLookupByLibrary.simpleMessage( "Verranno eliminati da tutti gli album."), "thisActionCannotBeUndone": MessageLookupByLibrary.simpleMessage( @@ -1347,7 +1347,7 @@ class MessageLookup extends MessageLookupByLibrary { "Questo indirizzo email è già registrato"), "thisImageHasNoExifData": MessageLookupByLibrary.simpleMessage( "Questa immagine non ha dati EXIF"), - "thisIsPersonVerificationId": m62, + "thisIsPersonVerificationId": m63, "thisIsYourVerificationId": MessageLookupByLibrary.simpleMessage( "Questo è il tuo ID di verifica"), "thisWillLogYouOutOfTheFollowingDevice": @@ -1363,7 +1363,7 @@ class MessageLookup extends MessageLookupByLibrary { "total": MessageLookupByLibrary.simpleMessage("totale"), "totalSize": MessageLookupByLibrary.simpleMessage("Dimensioni totali"), "trash": MessageLookupByLibrary.simpleMessage("Cestino"), - "trashDaysLeft": m63, + "trashDaysLeft": m64, "tryAgain": MessageLookupByLibrary.simpleMessage("Riprova"), "turnOnBackupForAutoUpload": MessageLookupByLibrary.simpleMessage( "Attiva il backup per caricare automaticamente i file aggiunti in questa cartella del dispositivo su ente."), @@ -1420,7 +1420,7 @@ class MessageLookup extends MessageLookupByLibrary { "useSelectedPhoto": MessageLookupByLibrary.simpleMessage("Usa la foto selezionata"), "usedSpace": MessageLookupByLibrary.simpleMessage("Spazio utilizzato"), - "validTill": m64, + "validTill": m65, "verificationFailedPleaseTryAgain": MessageLookupByLibrary.simpleMessage( "Verifica fallita, per favore prova di nuovo"), @@ -1428,7 +1428,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("ID di verifica"), "verify": MessageLookupByLibrary.simpleMessage("Verifica"), "verifyEmail": MessageLookupByLibrary.simpleMessage("Verifica email"), - "verifyEmailID": m65, + "verifyEmailID": m66, "verifyIDLabel": MessageLookupByLibrary.simpleMessage("Verifica"), "verifyPassword": MessageLookupByLibrary.simpleMessage("Verifica password"), @@ -1455,11 +1455,11 @@ class MessageLookup extends MessageLookupByLibrary { "weDontSupportEditingPhotosAndAlbumsThatYouDont": MessageLookupByLibrary.simpleMessage( "Non puoi modificare foto e album che non possiedi"), - "weHaveSendEmailTo": m66, + "weHaveSendEmailTo": m67, "weakStrength": MessageLookupByLibrary.simpleMessage("Debole"), "welcomeBack": MessageLookupByLibrary.simpleMessage("Bentornato/a!"), "yearly": MessageLookupByLibrary.simpleMessage("Annuale"), - "yearsAgo": m67, + "yearsAgo": m68, "yes": MessageLookupByLibrary.simpleMessage("Si"), "yesCancel": MessageLookupByLibrary.simpleMessage("Sì, cancella"), "yesConvertToViewer": MessageLookupByLibrary.simpleMessage( @@ -1489,7 +1489,7 @@ class MessageLookup extends MessageLookupByLibrary { "Non puoi condividere con te stesso"), "youDontHaveAnyArchivedItems": MessageLookupByLibrary.simpleMessage( "Non hai nulla di archiviato."), - "youHaveSuccessfullyFreedUp": m68, + "youHaveSuccessfullyFreedUp": m69, "yourAccountHasBeenDeleted": MessageLookupByLibrary.simpleMessage( "Il tuo account è stato eliminato"), "yourMap": MessageLookupByLibrary.simpleMessage("Your map"), diff --git a/mobile/lib/generated/intl/messages_nl.dart b/mobile/lib/generated/intl/messages_nl.dart index db1de2de5f..eedbb366f0 100644 --- a/mobile/lib/generated/intl/messages_nl.dart +++ b/mobile/lib/generated/intl/messages_nl.dart @@ -41,13 +41,16 @@ class MessageLookup extends MessageLookupByLibrary { static String m7(versionValue) => "Versie: ${versionValue}"; - static String m8(paymentProvider) => + static String m8(freeAmount, storageUnit) => + "${freeAmount} ${storageUnit} vrij"; + + static String m9(paymentProvider) => "Annuleer eerst uw bestaande abonnement bij ${paymentProvider}"; - static String m9(user) => + static String m10(user) => "${user} zal geen foto\'s meer kunnen toevoegen aan dit album\n\nDe gebruiker zal nog steeds bestaande foto\'s kunnen verwijderen die door hen zijn toegevoegd"; - static String m10(isFamilyMember, storageAmountInGb) => + static String m11(isFamilyMember, storageAmountInGb) => "${Intl.select(isFamilyMember, { 'true': 'Jouw familie heeft ${storageAmountInGb} GB geclaimd tot nu toe', @@ -55,166 +58,163 @@ class MessageLookup extends MessageLookupByLibrary { 'other': 'Je hebt ${storageAmountInGb} GB geclaimd tot nu toe!', })}"; - static String m11(albumName) => + static String m12(albumName) => "Gezamenlijke link aangemaakt voor ${albumName}"; - static String m12(familyAdminEmail) => + static String m13(familyAdminEmail) => "Neem contact op met ${familyAdminEmail} om uw abonnement te beheren"; - static String m13(provider) => + static String m14(provider) => "Neem contact met ons op via support@ente.io om uw ${provider} abonnement te beheren."; - static String m69(endpoint) => "Verbonden met ${endpoint}"; + static String m15(endpoint) => "Verbonden met ${endpoint}"; - static String m14(count) => + static String m16(count) => "${Intl.plural(count, one: 'Verwijder ${count} bestand', other: 'Verwijder ${count} bestanden')}"; - static String m15(currentlyDeleting, totalCount) => + static String m17(currentlyDeleting, totalCount) => "Verwijderen van ${currentlyDeleting} / ${totalCount}"; - static String m16(albumName) => + static String m18(albumName) => "Dit verwijdert de openbare link voor toegang tot \"${albumName}\"."; - static String m17(supportEmail) => + static String m19(supportEmail) => "Stuur een e-mail naar ${supportEmail} vanaf het door jou geregistreerde e-mailadres"; - static String m18(count, storageSaved) => + static String m20(count, storageSaved) => "Je hebt ${Intl.plural(count, one: '${count} dubbel bestand', other: '${count} dubbele bestanden')} opgeruimd, totaal (${storageSaved}!)"; - static String m19(count, formattedSize) => + static String m21(count, formattedSize) => "${count} bestanden, elk ${formattedSize}"; - static String m20(newEmail) => "E-mailadres gewijzigd naar ${newEmail}"; + static String m22(newEmail) => "E-mailadres gewijzigd naar ${newEmail}"; - static String m21(email) => + static String m23(email) => "${email} heeft geen Ente account.\n\nStuur ze een uitnodiging om foto\'s te delen."; - static String m22(count, formattedNumber) => + static String m24(count, formattedNumber) => "${Intl.plural(count, one: '1 bestand', other: '${formattedNumber} bestanden')} in dit album zijn veilig geback-upt"; - static String m23(count, formattedNumber) => + static String m25(count, formattedNumber) => "${Intl.plural(count, one: '1 bestand', other: '${formattedNumber} bestanden')} in dit album is veilig geback-upt"; - static String m24(storageAmountInGB) => + static String m26(storageAmountInGB) => "${storageAmountInGB} GB telkens als iemand zich aanmeldt voor een betaald abonnement en je code toepast"; - static String m25(freeAmount, storageUnit) => - "${freeAmount} ${storageUnit} vrij"; + static String m27(endDate) => "Gratis proefversie geldig tot ${endDate}"; - static String m26(endDate) => "Gratis proefversie geldig tot ${endDate}"; - - static String m27(count) => + static String m28(count) => "Je hebt nog steeds toegang tot ${Intl.plural(count, one: 'het', other: 'ze')} op Ente zolang je een actief abonnement hebt"; - static String m28(sizeInMBorGB) => "Maak ${sizeInMBorGB} vrij"; + static String m29(sizeInMBorGB) => "Maak ${sizeInMBorGB} vrij"; - static String m29(count, formattedSize) => + static String m30(count, formattedSize) => "${Intl.plural(count, one: 'Het kan verwijderd worden van het apparaat om ${formattedSize} vrij te maken', other: 'Ze kunnen verwijderd worden van het apparaat om ${formattedSize} vrij te maken')}"; - static String m30(currentlyProcessing, totalCount) => + static String m31(currentlyProcessing, totalCount) => "Verwerken van ${currentlyProcessing} / ${totalCount}"; - static String m31(count) => + static String m32(count) => "${Intl.plural(count, one: '${count} item', other: '${count} items')}"; - static String m32(expiryTime) => "Link vervalt op ${expiryTime}"; + static String m33(expiryTime) => "Link vervalt op ${expiryTime}"; - static String m33(count, formattedCount) => + static String m34(count, formattedCount) => "${Intl.plural(count, zero: 'geen herinneringen', one: '${formattedCount} herinnering', other: '${formattedCount} herinneringen')}"; - static String m34(count) => + static String m35(count) => "${Intl.plural(count, one: 'Bestand verplaatsen', other: 'Bestanden verplaatsen')}"; - static String m35(albumName) => "Succesvol verplaatst naar ${albumName}"; + static String m36(albumName) => "Succesvol verplaatst naar ${albumName}"; - static String m36(passwordStrengthValue) => + static String m37(passwordStrengthValue) => "Wachtwoord sterkte: ${passwordStrengthValue}"; - static String m37(providerName) => + static String m38(providerName) => "Praat met ${providerName} klantenservice als u in rekening bent gebracht"; - static String m38(endDate) => + static String m39(endDate) => "Gratis proefperiode geldig tot ${endDate}.\nU kunt naderhand een betaald abonnement kiezen."; - static String m39(toEmail) => "Stuur ons een e-mail op ${toEmail}"; + static String m40(toEmail) => "Stuur ons een e-mail op ${toEmail}"; - static String m40(toEmail) => + static String m41(toEmail) => "Verstuur de logboeken alstublieft naar ${toEmail}"; - static String m41(storeName) => "Beoordeel ons op ${storeName}"; + static String m42(storeName) => "Beoordeel ons op ${storeName}"; - static String m42(storageInGB) => + static String m43(storageInGB) => "Jullie krijgen allebei ${storageInGB} GB* gratis"; - static String m43(userEmail) => + static String m44(userEmail) => "${userEmail} zal worden verwijderd uit dit gedeelde album\n\nAlle door hen toegevoegde foto\'s worden ook uit het album verwijderd"; - static String m44(endDate) => "Wordt verlengd op ${endDate}"; + static String m45(endDate) => "Wordt verlengd op ${endDate}"; - static String m45(count) => + static String m46(count) => "${Intl.plural(count, one: '${count} resultaat gevonden', other: '${count} resultaten gevonden')}"; - static String m46(count) => "${count} geselecteerd"; + static String m47(count) => "${count} geselecteerd"; - static String m47(count, yourCount) => + static String m48(count, yourCount) => "${count} geselecteerd (${yourCount} van jou)"; - static String m48(verificationID) => + static String m49(verificationID) => "Hier is mijn verificatie-ID: ${verificationID} voor ente.io."; - static String m49(verificationID) => + static String m50(verificationID) => "Hey, kunt u bevestigen dat dit uw ente.io verificatie-ID is: ${verificationID}"; - static String m50(referralCode, referralStorageInGB) => + static String m51(referralCode, referralStorageInGB) => "Ente verwijzingscode: ${referralCode} \n\nPas het toe bij Instellingen → Algemeen → Verwijzingen om ${referralStorageInGB} GB gratis te krijgen nadat je je hebt aangemeld voor een betaald abonnement\n\nhttps://ente.io"; - static String m51(numberOfPeople) => + static String m52(numberOfPeople) => "${Intl.plural(numberOfPeople, zero: 'Deel met specifieke mensen', one: 'Gedeeld met 1 persoon', other: 'Gedeeld met ${numberOfPeople} mensen')}"; - static String m52(emailIDs) => "Gedeeld met ${emailIDs}"; - - static String m53(fileType) => - "Deze ${fileType} zal worden verwijderd van jouw apparaat."; + static String m53(emailIDs) => "Gedeeld met ${emailIDs}"; static String m54(fileType) => - "Deze ${fileType} staat zowel in Ente als op jouw apparaat."; + "Deze ${fileType} zal worden verwijderd van jouw apparaat."; static String m55(fileType) => + "Deze ${fileType} staat zowel in Ente als op jouw apparaat."; + + static String m56(fileType) => "Deze ${fileType} zal worden verwijderd uit Ente."; - static String m56(storageAmountInGB) => "${storageAmountInGB} GB"; + static String m57(storageAmountInGB) => "${storageAmountInGB} GB"; - static String m57( + static String m58( usedAmount, usedStorageUnit, totalAmount, totalStorageUnit) => "${usedAmount} ${usedStorageUnit} van ${totalAmount} ${totalStorageUnit} gebruikt"; - static String m58(id) => + static String m59(id) => "Jouw ${id} is al aan een ander Ente account gekoppeld.\nAls je jouw ${id} wilt gebruiken met dit account, neem dan contact op met onze klantenservice"; - static String m59(endDate) => "Uw abonnement loopt af op ${endDate}"; + static String m60(endDate) => "Uw abonnement loopt af op ${endDate}"; - static String m60(completed, total) => + static String m61(completed, total) => "${completed}/${total} herinneringen bewaard"; - static String m61(storageAmountInGB) => + static String m62(storageAmountInGB) => "Zij krijgen ook ${storageAmountInGB} GB"; - static String m62(email) => "Dit is de verificatie-ID van ${email}"; + static String m63(email) => "Dit is de verificatie-ID van ${email}"; - static String m63(count) => + static String m64(count) => "${Intl.plural(count, zero: '', one: '1 dag', other: '${count} dagen')}"; - static String m64(endDate) => "Geldig tot ${endDate}"; + static String m65(endDate) => "Geldig tot ${endDate}"; - static String m65(email) => "Verifieer ${email}"; + static String m66(email) => "Verifieer ${email}"; - static String m66(email) => + static String m67(email) => "We hebben een e-mail gestuurd naar ${email}"; - static String m67(count) => + static String m68(count) => "${Intl.plural(count, one: '${count} jaar geleden', other: '${count} jaar geleden')}"; - static String m68(storageSaved) => + static String m69(storageSaved) => "Je hebt ${storageSaved} succesvol vrijgemaakt!"; final messages = _notInlinedMessages(_notInlinedMessages); @@ -377,6 +377,7 @@ class MessageLookup extends MessageLookupByLibrary { "autoPairDesc": MessageLookupByLibrary.simpleMessage( "Automatisch koppelen werkt alleen met apparaten die Chromecast ondersteunen."), "available": MessageLookupByLibrary.simpleMessage("Beschikbaar"), + "availableStorageSpace": m8, "backedUpFolders": MessageLookupByLibrary.simpleMessage("Back-up mappen"), "backup": MessageLookupByLibrary.simpleMessage("Back-up"), @@ -401,10 +402,10 @@ class MessageLookup extends MessageLookupByLibrary { "canOnlyRemoveFilesOwnedByYou": MessageLookupByLibrary.simpleMessage( "Kan alleen bestanden verwijderen die jouw eigendom zijn"), "cancel": MessageLookupByLibrary.simpleMessage("Annuleer"), - "cancelOtherSubscription": m8, + "cancelOtherSubscription": m9, "cancelSubscription": MessageLookupByLibrary.simpleMessage("Abonnement opzeggen"), - "cannotAddMorePhotosAfterBecomingViewer": m9, + "cannotAddMorePhotosAfterBecomingViewer": m10, "cannotDeleteSharedFiles": MessageLookupByLibrary.simpleMessage( "Kan gedeelde bestanden niet verwijderen"), "castIPMismatchBody": MessageLookupByLibrary.simpleMessage( @@ -432,7 +433,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Claim gratis opslag"), "claimMore": MessageLookupByLibrary.simpleMessage("Claim meer!"), "claimed": MessageLookupByLibrary.simpleMessage("Geclaimd"), - "claimedStorageSoFar": m10, + "claimedStorageSoFar": m11, "cleanUncategorized": MessageLookupByLibrary.simpleMessage("Ongecategoriseerd opschonen"), "cleanUncategorizedDescription": MessageLookupByLibrary.simpleMessage( @@ -459,7 +460,7 @@ class MessageLookup extends MessageLookupByLibrary { "Maak een link waarmee mensen foto\'s in jouw gedeelde album kunnen toevoegen en bekijken zonder dat ze daarvoor een Ente app of account nodig hebben. Handig voor het verzamelen van foto\'s van evenementen."), "collaborativeLink": MessageLookupByLibrary.simpleMessage("Gezamenlijke link"), - "collaborativeLinkCreatedFor": m11, + "collaborativeLinkCreatedFor": m12, "collaborator": MessageLookupByLibrary.simpleMessage("Samenwerker"), "collaboratorsCanAddPhotosAndVideosToTheSharedAlbum": MessageLookupByLibrary.simpleMessage( @@ -489,10 +490,10 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Bevestig herstelsleutel"), "connectToDevice": MessageLookupByLibrary.simpleMessage( "Verbinding maken met apparaat"), - "contactFamilyAdmin": m12, + "contactFamilyAdmin": m13, "contactSupport": MessageLookupByLibrary.simpleMessage("Contacteer klantenservice"), - "contactToManageSubscription": m13, + "contactToManageSubscription": m14, "contacts": MessageLookupByLibrary.simpleMessage("Contacten"), "contents": MessageLookupByLibrary.simpleMessage("Inhoud"), "continueLabel": MessageLookupByLibrary.simpleMessage("Doorgaan"), @@ -536,7 +537,7 @@ class MessageLookup extends MessageLookupByLibrary { "currentUsageIs": MessageLookupByLibrary.simpleMessage("Huidig gebruik is "), "custom": MessageLookupByLibrary.simpleMessage("Aangepast"), - "customEndpoint": m69, + "customEndpoint": m15, "darkTheme": MessageLookupByLibrary.simpleMessage("Donker"), "dayToday": MessageLookupByLibrary.simpleMessage("Vandaag"), "dayYesterday": MessageLookupByLibrary.simpleMessage("Gisteren"), @@ -572,12 +573,12 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Verwijder van apparaat"), "deleteFromEnte": MessageLookupByLibrary.simpleMessage("Verwijder van Ente"), - "deleteItemCount": m14, + "deleteItemCount": m16, "deleteLocation": MessageLookupByLibrary.simpleMessage("Verwijder locatie"), "deletePhotos": MessageLookupByLibrary.simpleMessage("Foto\'s verwijderen"), - "deleteProgress": m15, + "deleteProgress": m17, "deleteReason1": MessageLookupByLibrary.simpleMessage( "Ik mis een belangrijke functie"), "deleteReason2": MessageLookupByLibrary.simpleMessage( @@ -617,7 +618,7 @@ class MessageLookup extends MessageLookupByLibrary { "Kijkers kunnen nog steeds screenshots maken of een kopie van je foto\'s opslaan met behulp van externe tools"), "disableDownloadWarningTitle": MessageLookupByLibrary.simpleMessage("Let op"), - "disableLinkMessage": m16, + "disableLinkMessage": m18, "disableTwofactor": MessageLookupByLibrary.simpleMessage( "Tweestapsverificatie uitschakelen"), "disablingTwofactorAuthentication": @@ -638,9 +639,9 @@ class MessageLookup extends MessageLookupByLibrary { "downloadFailed": MessageLookupByLibrary.simpleMessage("Download mislukt"), "downloading": MessageLookupByLibrary.simpleMessage("Downloaden..."), - "dropSupportEmail": m17, - "duplicateFileCountWithStorageSaved": m18, - "duplicateItemsGroup": m19, + "dropSupportEmail": m19, + "duplicateFileCountWithStorageSaved": m20, + "duplicateItemsGroup": m21, "edit": MessageLookupByLibrary.simpleMessage("Bewerken"), "editLocation": MessageLookupByLibrary.simpleMessage("Locatie bewerken"), @@ -653,8 +654,8 @@ class MessageLookup extends MessageLookupByLibrary { "Bewerkte locatie wordt alleen gezien binnen Ente"), "eligible": MessageLookupByLibrary.simpleMessage("gerechtigd"), "email": MessageLookupByLibrary.simpleMessage("E-mail"), - "emailChangedTo": m20, - "emailNoEnteAccount": m21, + "emailChangedTo": m22, + "emailNoEnteAccount": m23, "emailVerificationToggle": MessageLookupByLibrary.simpleMessage("E-mailverificatie"), "emailYourLogs": @@ -762,8 +763,8 @@ class MessageLookup extends MessageLookupByLibrary { "fileTypes": MessageLookupByLibrary.simpleMessage("Bestandstype"), "fileTypesAndNames": MessageLookupByLibrary.simpleMessage("Bestandstypen en namen"), - "filesBackedUpFromDevice": m22, - "filesBackedUpInAlbum": m23, + "filesBackedUpFromDevice": m24, + "filesBackedUpInAlbum": m25, "filesDeleted": MessageLookupByLibrary.simpleMessage("Bestanden verwijderd"), "filesSavedToGallery": MessageLookupByLibrary.simpleMessage( @@ -776,24 +777,23 @@ class MessageLookup extends MessageLookupByLibrary { "foundFaces": MessageLookupByLibrary.simpleMessage("Found faces"), "freeStorageClaimed": MessageLookupByLibrary.simpleMessage("Gratis opslag geclaimd"), - "freeStorageOnReferralSuccess": m24, - "freeStorageSpace": m25, + "freeStorageOnReferralSuccess": m26, "freeStorageUsable": MessageLookupByLibrary.simpleMessage("Gratis opslag bruikbaar"), "freeTrial": MessageLookupByLibrary.simpleMessage("Gratis proefversie"), - "freeTrialValidTill": m26, - "freeUpAccessPostDelete": m27, - "freeUpAmount": m28, + "freeTrialValidTill": m27, + "freeUpAccessPostDelete": m28, + "freeUpAmount": m29, "freeUpDeviceSpace": MessageLookupByLibrary.simpleMessage("Apparaatruimte vrijmaken"), "freeUpSpace": MessageLookupByLibrary.simpleMessage("Ruimte vrijmaken"), - "freeUpSpaceSaving": m29, + "freeUpSpaceSaving": m30, "galleryMemoryLimitInfo": MessageLookupByLibrary.simpleMessage( "Tot 1000 herinneringen getoond in de galerij"), "general": MessageLookupByLibrary.simpleMessage("Algemeen"), "generatingEncryptionKeys": MessageLookupByLibrary.simpleMessage( "Encryptiesleutels genereren..."), - "genericProgress": m30, + "genericProgress": m31, "goToSettings": MessageLookupByLibrary.simpleMessage("Ga naar instellingen"), "googlePlayId": MessageLookupByLibrary.simpleMessage("Google Play ID"), @@ -861,7 +861,7 @@ class MessageLookup extends MessageLookupByLibrary { "itLooksLikeSomethingWentWrongPleaseRetryAfterSome": MessageLookupByLibrary.simpleMessage( "Het lijkt erop dat er iets fout is gegaan. Probeer het later opnieuw. Als de fout zich blijft voordoen, neem dan contact op met ons supportteam."), - "itemCount": m31, + "itemCount": m32, "itemsShowTheNumberOfDaysRemainingBeforePermanentDeletion": MessageLookupByLibrary.simpleMessage( "Bestanden tonen het aantal resterende dagen voordat ze permanent worden verwijderd"), @@ -888,7 +888,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Apparaat limiet"), "linkEnabled": MessageLookupByLibrary.simpleMessage("Ingeschakeld"), "linkExpired": MessageLookupByLibrary.simpleMessage("Verlopen"), - "linkExpiresOn": m32, + "linkExpiresOn": m33, "linkExpiry": MessageLookupByLibrary.simpleMessage("Vervaldatum"), "linkHasExpired": MessageLookupByLibrary.simpleMessage("Link is vervallen"), @@ -963,7 +963,7 @@ class MessageLookup extends MessageLookupByLibrary { "maps": MessageLookupByLibrary.simpleMessage("Kaarten"), "mastodon": MessageLookupByLibrary.simpleMessage("Mastodon"), "matrix": MessageLookupByLibrary.simpleMessage("Matrix"), - "memoryCount": m33, + "memoryCount": m34, "merchandise": MessageLookupByLibrary.simpleMessage("Merchandise"), "mobileWebDesktop": MessageLookupByLibrary.simpleMessage("Mobiel, Web, Desktop"), @@ -973,12 +973,12 @@ class MessageLookup extends MessageLookupByLibrary { "Pas je zoekopdracht aan of zoek naar"), "moments": MessageLookupByLibrary.simpleMessage("Momenten"), "monthly": MessageLookupByLibrary.simpleMessage("Maandelijks"), - "moveItem": m34, + "moveItem": m35, "moveToAlbum": MessageLookupByLibrary.simpleMessage("Verplaats naar album"), "moveToHiddenAlbum": MessageLookupByLibrary.simpleMessage( "Verplaatsen naar verborgen album"), - "movedSuccessfullyTo": m35, + "movedSuccessfullyTo": m36, "movedToTrash": MessageLookupByLibrary.simpleMessage("Naar prullenbak verplaatst"), "movingFilesToAlbum": MessageLookupByLibrary.simpleMessage( @@ -1056,7 +1056,7 @@ class MessageLookup extends MessageLookupByLibrary { "passwordChangedSuccessfully": MessageLookupByLibrary.simpleMessage( "Wachtwoord succesvol aangepast"), "passwordLock": MessageLookupByLibrary.simpleMessage("Wachtwoord slot"), - "passwordStrength": m36, + "passwordStrength": m37, "passwordWarning": MessageLookupByLibrary.simpleMessage( "Wij slaan dit wachtwoord niet op, dus als je het vergeet, kunnen we je gegevens niet ontsleutelen"), "paymentDetails": @@ -1065,7 +1065,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Betaling mislukt"), "paymentFailedMessage": MessageLookupByLibrary.simpleMessage( "Helaas is je betaling mislukt. Neem contact op met support zodat we je kunnen helpen!"), - "paymentFailedTalkToProvider": m37, + "paymentFailedTalkToProvider": m38, "pendingItems": MessageLookupByLibrary.simpleMessage("Bestanden in behandeling"), "pendingSync": MessageLookupByLibrary.simpleMessage( @@ -1093,7 +1093,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Album bovenaan vastzetten"), "playOnTv": MessageLookupByLibrary.simpleMessage("Album afspelen op TV"), - "playStoreFreeTrialValidTill": m38, + "playStoreFreeTrialValidTill": m39, "playstoreSubscription": MessageLookupByLibrary.simpleMessage("PlayStore abonnement"), "pleaseCheckYourInternetConnectionAndTryAgain": @@ -1105,12 +1105,12 @@ class MessageLookup extends MessageLookupByLibrary { "pleaseContactSupportIfTheProblemPersists": MessageLookupByLibrary.simpleMessage( "Neem contact op met klantenservice als het probleem aanhoudt"), - "pleaseEmailUsAt": m39, + "pleaseEmailUsAt": m40, "pleaseGrantPermissions": MessageLookupByLibrary.simpleMessage( "Geef alstublieft toestemming"), "pleaseLoginAgain": MessageLookupByLibrary.simpleMessage("Log opnieuw in"), - "pleaseSendTheLogsTo": m40, + "pleaseSendTheLogsTo": m41, "pleaseTryAgain": MessageLookupByLibrary.simpleMessage("Probeer het nog eens"), "pleaseVerifyTheCodeYouHaveEntered": @@ -1145,7 +1145,7 @@ class MessageLookup extends MessageLookupByLibrary { "raiseTicket": MessageLookupByLibrary.simpleMessage("Meld probleem"), "rateTheApp": MessageLookupByLibrary.simpleMessage("Beoordeel de app"), "rateUs": MessageLookupByLibrary.simpleMessage("Beoordeel ons"), - "rateUsOnStore": m41, + "rateUsOnStore": m42, "recover": MessageLookupByLibrary.simpleMessage("Herstellen"), "recoverAccount": MessageLookupByLibrary.simpleMessage("Account herstellen"), @@ -1176,7 +1176,7 @@ class MessageLookup extends MessageLookupByLibrary { "1. Geef deze code aan je vrienden"), "referralStep2": MessageLookupByLibrary.simpleMessage( "2. Ze registreren voor een betaald plan"), - "referralStep3": m42, + "referralStep3": m43, "referrals": MessageLookupByLibrary.simpleMessage("Referenties"), "referralsAreCurrentlyPaused": MessageLookupByLibrary.simpleMessage( "Verwijzingen zijn momenteel gepauzeerd"), @@ -1202,7 +1202,7 @@ class MessageLookup extends MessageLookupByLibrary { "removeLink": MessageLookupByLibrary.simpleMessage("Verwijder link"), "removeParticipant": MessageLookupByLibrary.simpleMessage("Deelnemer verwijderen"), - "removeParticipantBody": m43, + "removeParticipantBody": m44, "removePublicLink": MessageLookupByLibrary.simpleMessage("Verwijder publieke link"), "removeShareItemsWarning": MessageLookupByLibrary.simpleMessage( @@ -1218,7 +1218,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Bestandsnaam wijzigen"), "renewSubscription": MessageLookupByLibrary.simpleMessage("Abonnement verlengen"), - "renewsOn": m44, + "renewsOn": m45, "reportABug": MessageLookupByLibrary.simpleMessage("Een fout melden"), "reportBug": MessageLookupByLibrary.simpleMessage("Fout melden"), "resendEmail": @@ -1281,7 +1281,7 @@ class MessageLookup extends MessageLookupByLibrary { "Foto\'s groeperen die in een bepaalde straal van een foto worden genomen"), "searchPeopleEmptySection": MessageLookupByLibrary.simpleMessage( "Nodig mensen uit, en je ziet alle foto\'s die door hen worden gedeeld hier"), - "searchResultCount": m45, + "searchResultCount": m46, "security": MessageLookupByLibrary.simpleMessage("Beveiliging"), "selectALocation": MessageLookupByLibrary.simpleMessage("Selecteer een locatie"), @@ -1308,8 +1308,8 @@ class MessageLookup extends MessageLookupByLibrary { "selectedItemsWillBeDeletedFromAllAlbumsAndMoved": MessageLookupByLibrary.simpleMessage( "Geselecteerde bestanden worden verwijderd uit alle albums en verplaatst naar de prullenbak."), - "selectedPhotos": m46, - "selectedPhotosWithYours": m47, + "selectedPhotos": m47, + "selectedPhotosWithYours": m48, "send": MessageLookupByLibrary.simpleMessage("Verzenden"), "sendEmail": MessageLookupByLibrary.simpleMessage("E-mail versturen"), "sendInvite": @@ -1335,16 +1335,16 @@ class MessageLookup extends MessageLookupByLibrary { "shareAnAlbumNow": MessageLookupByLibrary.simpleMessage("Deel nu een album"), "shareLink": MessageLookupByLibrary.simpleMessage("Link delen"), - "shareMyVerificationID": m48, + "shareMyVerificationID": m49, "shareOnlyWithThePeopleYouWant": MessageLookupByLibrary.simpleMessage( "Deel alleen met de mensen die u wilt"), - "shareTextConfirmOthersVerificationID": m49, + "shareTextConfirmOthersVerificationID": m50, "shareTextRecommendUsingEnte": MessageLookupByLibrary.simpleMessage( "Download Ente zodat we gemakkelijk foto\'s en video\'s in originele kwaliteit kunnen delen\n\nhttps://ente.io"), - "shareTextReferralCode": m50, + "shareTextReferralCode": m51, "shareWithNonenteUsers": MessageLookupByLibrary.simpleMessage( "Delen met niet-Ente gebruikers"), - "shareWithPeopleSectionTitle": m51, + "shareWithPeopleSectionTitle": m52, "shareYourFirstAlbum": MessageLookupByLibrary.simpleMessage("Deel jouw eerste album"), "sharedAlbumSectionDescription": MessageLookupByLibrary.simpleMessage( @@ -1355,7 +1355,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Nieuwe gedeelde foto\'s"), "sharedPhotoNotificationsExplanation": MessageLookupByLibrary.simpleMessage( "Ontvang meldingen wanneer iemand een foto toevoegt aan een gedeeld album waar je deel van uitmaakt"), - "sharedWith": m52, + "sharedWith": m53, "sharedWithMe": MessageLookupByLibrary.simpleMessage("Gedeeld met mij"), "sharedWithYou": MessageLookupByLibrary.simpleMessage("Gedeeld met jou"), @@ -1370,11 +1370,11 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Log uit op andere apparaten"), "signUpTerms": MessageLookupByLibrary.simpleMessage( "Ik ga akkoord met de gebruiksvoorwaarden en privacybeleid"), - "singleFileDeleteFromDevice": m53, + "singleFileDeleteFromDevice": m54, "singleFileDeleteHighlight": MessageLookupByLibrary.simpleMessage( "Het wordt uit alle albums verwijderd."), - "singleFileInBothLocalAndRemote": m54, - "singleFileInRemoteOnly": m55, + "singleFileInBothLocalAndRemote": m55, + "singleFileInRemoteOnly": m56, "skip": MessageLookupByLibrary.simpleMessage("Overslaan"), "social": MessageLookupByLibrary.simpleMessage("Sociale media"), "someItemsAreInBothEnteAndYourDevice": MessageLookupByLibrary.simpleMessage( @@ -1416,13 +1416,13 @@ class MessageLookup extends MessageLookupByLibrary { "storage": MessageLookupByLibrary.simpleMessage("Opslagruimte"), "storageBreakupFamily": MessageLookupByLibrary.simpleMessage("Familie"), "storageBreakupYou": MessageLookupByLibrary.simpleMessage("Jij"), - "storageInGB": m56, + "storageInGB": m57, "storageLimitExceeded": MessageLookupByLibrary.simpleMessage("Opslaglimiet overschreden"), - "storageUsageInfo": m57, + "storageUsageInfo": m58, "strongStrength": MessageLookupByLibrary.simpleMessage("Sterk"), - "subAlreadyLinkedErrMessage": m58, - "subWillBeCancelledOn": m59, + "subAlreadyLinkedErrMessage": m59, + "subWillBeCancelledOn": m60, "subscribe": MessageLookupByLibrary.simpleMessage("Abonneer"), "subscribeToEnableSharing": MessageLookupByLibrary.simpleMessage( "Het lijkt erop dat je abonnement is verlopen. Abonneer om delen mogelijk te maken."), @@ -1439,7 +1439,7 @@ class MessageLookup extends MessageLookupByLibrary { "suggestFeatures": MessageLookupByLibrary.simpleMessage("Features voorstellen"), "support": MessageLookupByLibrary.simpleMessage("Ondersteuning"), - "syncProgress": m60, + "syncProgress": m61, "syncStopped": MessageLookupByLibrary.simpleMessage("Synchronisatie gestopt"), "syncing": MessageLookupByLibrary.simpleMessage("Synchroniseren..."), @@ -1467,7 +1467,7 @@ class MessageLookup extends MessageLookupByLibrary { "theseItemsWillBeDeletedFromYourDevice": MessageLookupByLibrary.simpleMessage( "Deze bestanden zullen worden verwijderd van uw apparaat."), - "theyAlsoGetXGb": m61, + "theyAlsoGetXGb": m62, "theyWillBeDeletedFromAllAlbums": MessageLookupByLibrary.simpleMessage( "Ze zullen uit alle albums worden verwijderd."), "thisActionCannotBeUndone": MessageLookupByLibrary.simpleMessage( @@ -1483,7 +1483,7 @@ class MessageLookup extends MessageLookupByLibrary { "Dit e-mailadres is al in gebruik"), "thisImageHasNoExifData": MessageLookupByLibrary.simpleMessage( "Deze foto heeft geen exif gegevens"), - "thisIsPersonVerificationId": m62, + "thisIsPersonVerificationId": m63, "thisIsYourVerificationId": MessageLookupByLibrary.simpleMessage("Dit is uw verificatie-ID"), "thisWillLogYouOutOfTheFollowingDevice": @@ -1500,7 +1500,7 @@ class MessageLookup extends MessageLookupByLibrary { "total": MessageLookupByLibrary.simpleMessage("totaal"), "totalSize": MessageLookupByLibrary.simpleMessage("Totale grootte"), "trash": MessageLookupByLibrary.simpleMessage("Prullenbak"), - "trashDaysLeft": m63, + "trashDaysLeft": m64, "tryAgain": MessageLookupByLibrary.simpleMessage("Probeer opnieuw"), "turnOnBackupForAutoUpload": MessageLookupByLibrary.simpleMessage( "Schakel back-up in om bestanden die toegevoegd zijn aan deze map op dit apparaat automatisch te uploaden."), @@ -1555,7 +1555,7 @@ class MessageLookup extends MessageLookupByLibrary { "useSelectedPhoto": MessageLookupByLibrary.simpleMessage("Gebruik geselecteerde foto"), "usedSpace": MessageLookupByLibrary.simpleMessage("Gebruikte ruimte"), - "validTill": m64, + "validTill": m65, "verificationFailedPleaseTryAgain": MessageLookupByLibrary.simpleMessage( "Verificatie mislukt, probeer het opnieuw"), @@ -1563,7 +1563,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Verificatie ID"), "verify": MessageLookupByLibrary.simpleMessage("Verifiëren"), "verifyEmail": MessageLookupByLibrary.simpleMessage("Bevestig e-mail"), - "verifyEmailID": m65, + "verifyEmailID": m66, "verifyIDLabel": MessageLookupByLibrary.simpleMessage("Verifiëren"), "verifyPasskey": MessageLookupByLibrary.simpleMessage("Bevestig passkey"), @@ -1596,11 +1596,11 @@ class MessageLookup extends MessageLookupByLibrary { "weDontSupportEditingPhotosAndAlbumsThatYouDont": MessageLookupByLibrary.simpleMessage( "We ondersteunen het bewerken van foto\'s en albums waar je niet de eigenaar van bent nog niet"), - "weHaveSendEmailTo": m66, + "weHaveSendEmailTo": m67, "weakStrength": MessageLookupByLibrary.simpleMessage("Zwak"), "welcomeBack": MessageLookupByLibrary.simpleMessage("Welkom terug!"), "yearly": MessageLookupByLibrary.simpleMessage("Jaarlijks"), - "yearsAgo": m67, + "yearsAgo": m68, "yes": MessageLookupByLibrary.simpleMessage("Ja"), "yesCancel": MessageLookupByLibrary.simpleMessage("Ja, opzeggen"), "yesConvertToViewer": @@ -1630,7 +1630,7 @@ class MessageLookup extends MessageLookupByLibrary { "Je kunt niet met jezelf delen"), "youDontHaveAnyArchivedItems": MessageLookupByLibrary.simpleMessage( "U heeft geen gearchiveerde bestanden."), - "youHaveSuccessfullyFreedUp": m68, + "youHaveSuccessfullyFreedUp": m69, "yourAccountHasBeenDeleted": MessageLookupByLibrary.simpleMessage("Je account is verwijderd"), "yourMap": MessageLookupByLibrary.simpleMessage("Jouw kaart"), diff --git a/mobile/lib/generated/intl/messages_pl.dart b/mobile/lib/generated/intl/messages_pl.dart index 0e16b5830b..32689eca5e 100644 --- a/mobile/lib/generated/intl/messages_pl.dart +++ b/mobile/lib/generated/intl/messages_pl.dart @@ -26,7 +26,7 @@ class MessageLookup extends MessageLookupByLibrary { static String m1(count) => "${Intl.plural(count, zero: 'Add viewer', one: 'Add viewer', other: 'Add viewers')}"; - static String m36(passwordStrengthValue) => + static String m37(passwordStrengthValue) => "Siła hasła: ${passwordStrengthValue}"; final messages = _notInlinedMessages(_notInlinedMessages); @@ -156,7 +156,7 @@ class MessageLookup extends MessageLookupByLibrary { "password": MessageLookupByLibrary.simpleMessage("Hasło"), "passwordChangedSuccessfully": MessageLookupByLibrary.simpleMessage( "Hasło zostało pomyślnie zmienione"), - "passwordStrength": m36, + "passwordStrength": m37, "passwordWarning": MessageLookupByLibrary.simpleMessage( "Nie przechowujemy tego hasła, więc jeśli go zapomnisz, nie będziemy w stanie odszyfrować Twoich danych"), "pleaseTryAgain": diff --git a/mobile/lib/generated/intl/messages_pt.dart b/mobile/lib/generated/intl/messages_pt.dart index d134c5306d..025bf911fd 100644 --- a/mobile/lib/generated/intl/messages_pt.dart +++ b/mobile/lib/generated/intl/messages_pt.dart @@ -41,13 +41,16 @@ class MessageLookup extends MessageLookupByLibrary { static String m7(versionValue) => "Versão: ${versionValue}"; - static String m8(paymentProvider) => + static String m8(freeAmount, storageUnit) => + "${freeAmount} ${storageUnit} livre"; + + static String m9(paymentProvider) => "Por favor, cancele sua assinatura existente do ${paymentProvider} primeiro"; - static String m9(user) => + static String m10(user) => "${user} Não poderá adicionar mais fotos a este álbum\n\nEles ainda poderão remover as fotos existentes adicionadas por eles"; - static String m10(isFamilyMember, storageAmountInGb) => + static String m11(isFamilyMember, storageAmountInGb) => "${Intl.select(isFamilyMember, { 'true': 'Sua família reeinvindicou ${storageAmountInGb} GB até agora', @@ -55,162 +58,159 @@ class MessageLookup extends MessageLookupByLibrary { 'other': 'Você reeinvindicou ${storageAmountInGb} GB até agora', })}"; - static String m11(albumName) => "Link colaborativo criado para ${albumName}"; + static String m12(albumName) => "Link colaborativo criado para ${albumName}"; - static String m12(familyAdminEmail) => + static String m13(familyAdminEmail) => "Entre em contato com ${familyAdminEmail} para gerenciar sua assinatura"; - static String m13(provider) => + static String m14(provider) => "Entre em contato conosco pelo e-mail support@ente.io para gerenciar sua assinatura ${provider}."; - static String m69(endpoint) => "Conectado a ${endpoint}"; + static String m15(endpoint) => "Conectado a ${endpoint}"; - static String m14(count) => + static String m16(count) => "${Intl.plural(count, one: 'Excluir ${count} item', other: 'Excluir ${count} itens')}"; - static String m15(currentlyDeleting, totalCount) => + static String m17(currentlyDeleting, totalCount) => "Excluindo ${currentlyDeleting} / ${totalCount}"; - static String m16(albumName) => + static String m18(albumName) => "Isso removerá o link público para acessar \"${albumName}\"."; - static String m17(supportEmail) => + static String m19(supportEmail) => "Por favor, envie um e-mail para ${supportEmail} a partir do seu endereço de e-mail registrado"; - static String m18(count, storageSaved) => + static String m20(count, storageSaved) => "Você limpou ${Intl.plural(count, one: '${count} arquivo duplicado', other: '${count} arquivos duplicados')}, salvando (${storageSaved}!)"; - static String m19(count, formattedSize) => + static String m21(count, formattedSize) => "${count} Arquivos, ${formattedSize} cada"; - static String m20(newEmail) => "E-mail alterado para ${newEmail}"; + static String m22(newEmail) => "E-mail alterado para ${newEmail}"; - static String m21(email) => + static String m23(email) => "${email} não possui uma conta Ente.\n\nEnvie um convite para compartilhar fotos."; - static String m22(count, formattedNumber) => + static String m24(count, formattedNumber) => "${Intl.plural(count, one: '1 arquivo', other: '${formattedNumber} arquivos')} neste dispositivo teve um backup seguro"; - static String m23(count, formattedNumber) => + static String m25(count, formattedNumber) => "${Intl.plural(count, one: '1 arquivo', other: '${formattedNumber} arquivos')} neste álbum teve um backup seguro"; - static String m24(storageAmountInGB) => + static String m26(storageAmountInGB) => "${storageAmountInGB} GB cada vez que alguém se inscrever para um plano pago e aplica o seu código"; - static String m25(freeAmount, storageUnit) => - "${freeAmount} ${storageUnit} livre"; + static String m27(endDate) => "Teste gratuito acaba em ${endDate}"; - static String m26(endDate) => "Teste gratuito acaba em ${endDate}"; - - static String m27(count) => + static String m28(count) => "Você ainda pode acessar ${Intl.plural(count, one: 'ele', other: 'eles')} no Ente contanto que você tenha uma assinatura ativa"; - static String m28(sizeInMBorGB) => "Liberar ${sizeInMBorGB}"; + static String m29(sizeInMBorGB) => "Liberar ${sizeInMBorGB}"; - static String m29(count, formattedSize) => + static String m30(count, formattedSize) => "${Intl.plural(count, one: 'Pode ser excluído do dispositivo para liberar ${formattedSize}', other: 'Eles podem ser excluídos do dispositivo para liberar ${formattedSize}')}"; - static String m30(currentlyProcessing, totalCount) => + static String m31(currentlyProcessing, totalCount) => "Processando ${currentlyProcessing} / ${totalCount}"; - static String m31(count) => + static String m32(count) => "${Intl.plural(count, one: '${count} item', other: '${count} items')}"; - static String m32(expiryTime) => "O link irá expirar em ${expiryTime}"; + static String m33(expiryTime) => "O link irá expirar em ${expiryTime}"; - static String m33(count, formattedCount) => + static String m34(count, formattedCount) => "${Intl.plural(count, zero: 'no memories', one: '${formattedCount} memory', other: '${formattedCount} memories')}"; - static String m34(count) => + static String m35(count) => "${Intl.plural(count, one: 'Mover item', other: 'Mover itens')}"; - static String m35(albumName) => "Movido com sucesso para ${albumName}"; + static String m36(albumName) => "Movido com sucesso para ${albumName}"; - static String m36(passwordStrengthValue) => + static String m37(passwordStrengthValue) => "Segurança da senha: ${passwordStrengthValue}"; - static String m37(providerName) => + static String m38(providerName) => "Por favor, fale com o suporte ${providerName} se você foi cobrado"; - static String m38(endDate) => + static String m39(endDate) => "Teste gratuito válido até ${endDate}.\nVocê pode escolher um plano pago depois."; - static String m39(toEmail) => + static String m40(toEmail) => "Por favor, envie-nos um e-mail para ${toEmail}"; - static String m40(toEmail) => "Por favor, envie os logs para \n${toEmail}"; + static String m41(toEmail) => "Por favor, envie os logs para \n${toEmail}"; - static String m41(storeName) => "Avalie-nos em ${storeName}"; + static String m42(storeName) => "Avalie-nos em ${storeName}"; - static String m42(storageInGB) => "3. Ambos ganham ${storageInGB} GB* grátis"; + static String m43(storageInGB) => "3. Ambos ganham ${storageInGB} GB* grátis"; - static String m43(userEmail) => + static String m44(userEmail) => "${userEmail} será removido deste álbum compartilhado\n\nQuaisquer fotos adicionadas por eles também serão removidas do álbum"; - static String m44(endDate) => "Renovação de assinatura em ${endDate}"; + static String m45(endDate) => "Renovação de assinatura em ${endDate}"; - static String m45(count) => + static String m46(count) => "${Intl.plural(count, one: '${count} resultado encontrado', other: '${count} resultado encontrado')}"; - static String m46(count) => "${count} Selecionados"; + static String m47(count) => "${count} Selecionados"; - static String m47(count, yourCount) => + static String m48(count, yourCount) => "${count} Selecionado (${yourCount} seus)"; - static String m48(verificationID) => + static String m49(verificationID) => "Aqui está meu ID de verificação para o Ente.io: ${verificationID}"; - static String m49(verificationID) => + static String m50(verificationID) => "Ei, você pode confirmar que este é seu ID de verificação do Ente.io? ${verificationID}"; - static String m50(referralCode, referralStorageInGB) => + static String m51(referralCode, referralStorageInGB) => "Código de referência do ente: ${referralCode} \n\nAplique em Configurações → Geral → Indicações para obter ${referralStorageInGB} GB gratuitamente após a sua inscrição em um plano pago\n\nhttps://ente.io"; - static String m51(numberOfPeople) => + static String m52(numberOfPeople) => "${Intl.plural(numberOfPeople, zero: 'Compartilhe com pessoas específicas', one: 'Compartilhado com 1 pessoa', other: 'Compartilhado com ${numberOfPeople} pessoas')}"; - static String m52(emailIDs) => "Compartilhado com ${emailIDs}"; - - static String m53(fileType) => - "Este ${fileType} será excluído do seu dispositivo."; + static String m53(emailIDs) => "Compartilhado com ${emailIDs}"; static String m54(fileType) => + "Este ${fileType} será excluído do seu dispositivo."; + + static String m55(fileType) => "Este ${fileType} está tanto no Ente quanto no seu dispositivo."; - static String m55(fileType) => "Este ${fileType} será excluído do Ente."; + static String m56(fileType) => "Este ${fileType} será excluído do Ente."; - static String m56(storageAmountInGB) => "${storageAmountInGB} GB"; + static String m57(storageAmountInGB) => "${storageAmountInGB} GB"; - static String m57( + static String m58( usedAmount, usedStorageUnit, totalAmount, totalStorageUnit) => "${usedAmount} ${usedStorageUnit} de ${totalAmount} ${totalStorageUnit} usado"; - static String m58(id) => + static String m59(id) => "Seu ${id} já está vinculado a outra conta Ente.\nSe você gostaria de usar seu ${id} com esta conta, por favor contate nosso suporte\'\'"; - static String m59(endDate) => "Sua assinatura será cancelada em ${endDate}"; + static String m60(endDate) => "Sua assinatura será cancelada em ${endDate}"; - static String m60(completed, total) => + static String m61(completed, total) => "${completed}/${total} memórias preservadas"; - static String m61(storageAmountInGB) => + static String m62(storageAmountInGB) => "Eles também recebem ${storageAmountInGB} GB"; - static String m62(email) => "Este é o ID de verificação de ${email}"; + static String m63(email) => "Este é o ID de verificação de ${email}"; - static String m63(count) => + static String m64(count) => "${Intl.plural(count, zero: '', one: '1 dia', other: '${count} dias')}"; - static String m64(endDate) => "Válido até ${endDate}"; + static String m65(endDate) => "Válido até ${endDate}"; - static String m65(email) => "Verificar ${email}"; + static String m66(email) => "Verificar ${email}"; - static String m66(email) => "Enviamos um e-mail à ${email}"; + static String m67(email) => "Enviamos um e-mail à ${email}"; - static String m67(count) => + static String m68(count) => "${Intl.plural(count, one: '${count} anos atrás', other: '${count} anos atrás')}"; - static String m68(storageSaved) => + static String m69(storageSaved) => "Você liberou ${storageSaved} com sucesso!"; final messages = _notInlinedMessages(_notInlinedMessages); @@ -370,11 +370,14 @@ class MessageLookup extends MessageLookupByLibrary { "Você verá dispositivos disponíveis para transmitir aqui."), "autoCastiOSPermission": MessageLookupByLibrary.simpleMessage( "Certifique-se de que as permissões de Rede local estão ativadas para o aplicativo de Fotos Ente, em Configurações."), + "autoLogoutMessage": MessageLookupByLibrary.simpleMessage( + "Devido a erros técnicos, você foi desconectado. Pedimos desculpas pelo inconveniente."), "autoPair": MessageLookupByLibrary.simpleMessage("Pareamento automático"), "autoPairDesc": MessageLookupByLibrary.simpleMessage( "O pareamento automático funciona apenas com dispositivos que suportam o Chromecast."), "available": MessageLookupByLibrary.simpleMessage("Disponível"), + "availableStorageSpace": m8, "backedUpFolders": MessageLookupByLibrary.simpleMessage("Backup de pastas concluído"), "backup": MessageLookupByLibrary.simpleMessage("Backup"), @@ -400,10 +403,10 @@ class MessageLookup extends MessageLookupByLibrary { "canOnlyRemoveFilesOwnedByYou": MessageLookupByLibrary.simpleMessage( "Só é possível remover arquivos de sua propriedade"), "cancel": MessageLookupByLibrary.simpleMessage("Cancelar"), - "cancelOtherSubscription": m8, + "cancelOtherSubscription": m9, "cancelSubscription": MessageLookupByLibrary.simpleMessage("Cancelar assinatura"), - "cannotAddMorePhotosAfterBecomingViewer": m9, + "cannotAddMorePhotosAfterBecomingViewer": m10, "cannotDeleteSharedFiles": MessageLookupByLibrary.simpleMessage( "Não é possível excluir arquivos compartilhados"), "castIPMismatchBody": MessageLookupByLibrary.simpleMessage( @@ -431,7 +434,7 @@ class MessageLookup extends MessageLookupByLibrary { "Reivindicar armazenamento gratuito"), "claimMore": MessageLookupByLibrary.simpleMessage("Reivindique mais!"), "claimed": MessageLookupByLibrary.simpleMessage("Reivindicado"), - "claimedStorageSoFar": m10, + "claimedStorageSoFar": m11, "cleanUncategorized": MessageLookupByLibrary.simpleMessage("Limpar Sem Categoria"), "cleanUncategorizedDescription": MessageLookupByLibrary.simpleMessage( @@ -458,7 +461,7 @@ class MessageLookup extends MessageLookupByLibrary { "Crie um link para permitir que as pessoas adicionem e vejam fotos no seu álbum compartilhado sem a necessidade do aplicativo ou uma conta Ente. Ótimo para colecionar fotos de eventos."), "collaborativeLink": MessageLookupByLibrary.simpleMessage("Link Colaborativo"), - "collaborativeLinkCreatedFor": m11, + "collaborativeLinkCreatedFor": m12, "collaborator": MessageLookupByLibrary.simpleMessage("Colaborador"), "collaboratorsCanAddPhotosAndVideosToTheSharedAlbum": MessageLookupByLibrary.simpleMessage( @@ -487,10 +490,10 @@ class MessageLookup extends MessageLookupByLibrary { "Confirme sua chave de recuperação"), "connectToDevice": MessageLookupByLibrary.simpleMessage("Conectar ao dispositivo"), - "contactFamilyAdmin": m12, + "contactFamilyAdmin": m13, "contactSupport": MessageLookupByLibrary.simpleMessage("Contate o suporte"), - "contactToManageSubscription": m13, + "contactToManageSubscription": m14, "contacts": MessageLookupByLibrary.simpleMessage("Contatos"), "contents": MessageLookupByLibrary.simpleMessage("Conteúdos"), "continueLabel": MessageLookupByLibrary.simpleMessage("Continuar"), @@ -533,7 +536,7 @@ class MessageLookup extends MessageLookupByLibrary { "currentUsageIs": MessageLookupByLibrary.simpleMessage("O uso atual é "), "custom": MessageLookupByLibrary.simpleMessage("Personalizado"), - "customEndpoint": m69, + "customEndpoint": m15, "darkTheme": MessageLookupByLibrary.simpleMessage("Escuro"), "dayToday": MessageLookupByLibrary.simpleMessage("Hoje"), "dayYesterday": MessageLookupByLibrary.simpleMessage("Ontem"), @@ -569,10 +572,10 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Excluir do dispositivo"), "deleteFromEnte": MessageLookupByLibrary.simpleMessage("Excluir do Ente"), - "deleteItemCount": m14, + "deleteItemCount": m16, "deleteLocation": MessageLookupByLibrary.simpleMessage("Excluir Local"), "deletePhotos": MessageLookupByLibrary.simpleMessage("Excluir fotos"), - "deleteProgress": m15, + "deleteProgress": m17, "deleteReason1": MessageLookupByLibrary.simpleMessage( "Está faltando um recurso-chave que eu preciso"), "deleteReason2": MessageLookupByLibrary.simpleMessage( @@ -611,7 +614,7 @@ class MessageLookup extends MessageLookupByLibrary { "Os espectadores ainda podem tirar screenshots ou salvar uma cópia de suas fotos usando ferramentas externas"), "disableDownloadWarningTitle": MessageLookupByLibrary.simpleMessage("Observe"), - "disableLinkMessage": m16, + "disableLinkMessage": m18, "disableTwofactor": MessageLookupByLibrary.simpleMessage( "Desativar autenticação de dois fatores"), "disablingTwofactorAuthentication": @@ -635,9 +638,9 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Falha no download"), "downloading": MessageLookupByLibrary.simpleMessage("Fazendo download..."), - "dropSupportEmail": m17, - "duplicateFileCountWithStorageSaved": m18, - "duplicateItemsGroup": m19, + "dropSupportEmail": m19, + "duplicateFileCountWithStorageSaved": m20, + "duplicateItemsGroup": m21, "edit": MessageLookupByLibrary.simpleMessage("Editar"), "editLocation": MessageLookupByLibrary.simpleMessage("Editar local"), "editLocationTagTitle": @@ -648,8 +651,8 @@ class MessageLookup extends MessageLookupByLibrary { "Edições para local só serão vistas dentro do Ente"), "eligible": MessageLookupByLibrary.simpleMessage("elegível"), "email": MessageLookupByLibrary.simpleMessage("E-mail"), - "emailChangedTo": m20, - "emailNoEnteAccount": m21, + "emailChangedTo": m22, + "emailNoEnteAccount": m23, "emailVerificationToggle": MessageLookupByLibrary.simpleMessage("Verificação de e-mail"), "emailYourLogs": @@ -754,8 +757,8 @@ class MessageLookup extends MessageLookupByLibrary { "fileTypes": MessageLookupByLibrary.simpleMessage("Tipos de arquivo"), "fileTypesAndNames": MessageLookupByLibrary.simpleMessage("Tipos de arquivo e nomes"), - "filesBackedUpFromDevice": m22, - "filesBackedUpInAlbum": m23, + "filesBackedUpFromDevice": m24, + "filesBackedUpInAlbum": m25, "filesDeleted": MessageLookupByLibrary.simpleMessage("Arquivos excluídos"), "filesSavedToGallery": @@ -771,24 +774,25 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Rostos encontrados"), "freeStorageClaimed": MessageLookupByLibrary.simpleMessage( "Armazenamento gratuito reivindicado"), - "freeStorageOnReferralSuccess": m24, - "freeStorageSpace": m25, + "freeStorageOnReferralSuccess": m26, "freeStorageUsable": MessageLookupByLibrary.simpleMessage( "Armazenamento livre utilizável"), "freeTrial": MessageLookupByLibrary.simpleMessage("Teste gratuito"), - "freeTrialValidTill": m26, - "freeUpAccessPostDelete": m27, - "freeUpAmount": m28, + "freeTrialValidTill": m27, + "freeUpAccessPostDelete": m28, + "freeUpAmount": m29, "freeUpDeviceSpace": MessageLookupByLibrary.simpleMessage( "Liberar espaço no dispositivo"), + "freeUpDeviceSpaceDesc": MessageLookupByLibrary.simpleMessage( + "Economize espaço no seu dispositivo limpando arquivos que já foram salvos em backup."), "freeUpSpace": MessageLookupByLibrary.simpleMessage("Liberar espaço"), - "freeUpSpaceSaving": m29, + "freeUpSpaceSaving": m30, "galleryMemoryLimitInfo": MessageLookupByLibrary.simpleMessage( "Até 1000 memórias mostradas na galeria"), "general": MessageLookupByLibrary.simpleMessage("Geral"), "generatingEncryptionKeys": MessageLookupByLibrary.simpleMessage( "Gerando chaves de criptografia..."), - "genericProgress": m30, + "genericProgress": m31, "goToSettings": MessageLookupByLibrary.simpleMessage("Ir para Configurações"), "googlePlayId": @@ -857,7 +861,7 @@ class MessageLookup extends MessageLookupByLibrary { "itLooksLikeSomethingWentWrongPleaseRetryAfterSome": MessageLookupByLibrary.simpleMessage( "Parece que algo deu errado. Por favor, tente novamente mais tarde. Se o erro persistir, entre em contato com nossa equipe de suporte."), - "itemCount": m31, + "itemCount": m32, "itemsShowTheNumberOfDaysRemainingBeforePermanentDeletion": MessageLookupByLibrary.simpleMessage( "Os itens mostram o número de dias restantes antes da exclusão permanente"), @@ -885,7 +889,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Limite do dispositivo"), "linkEnabled": MessageLookupByLibrary.simpleMessage("Ativado"), "linkExpired": MessageLookupByLibrary.simpleMessage("Expirado"), - "linkExpiresOn": m32, + "linkExpiresOn": m33, "linkExpiry": MessageLookupByLibrary.simpleMessage("Expiração do link"), "linkHasExpired": MessageLookupByLibrary.simpleMessage("O link expirou"), @@ -907,7 +911,7 @@ class MessageLookup extends MessageLookupByLibrary { "loadMessage7": MessageLookupByLibrary.simpleMessage( "Nossos aplicativos móveis são executados em segundo plano para criptografar e fazer backup de quaisquer novas fotos que você clique"), "loadMessage8": MessageLookupByLibrary.simpleMessage( - "web.ente.io tem um upload rápido"), + "web.ente.io tem um envio mais rápido"), "loadMessage9": MessageLookupByLibrary.simpleMessage( "Nós usamos Xchacha20Poly1305 para criptografar seus dados com segurança"), "loadingExifData": @@ -961,7 +965,7 @@ class MessageLookup extends MessageLookupByLibrary { "maps": MessageLookupByLibrary.simpleMessage("Mapas"), "mastodon": MessageLookupByLibrary.simpleMessage("Mastodon"), "matrix": MessageLookupByLibrary.simpleMessage("Matrix"), - "memoryCount": m33, + "memoryCount": m34, "merchandise": MessageLookupByLibrary.simpleMessage("Produtos"), "mlIndexingDescription": MessageLookupByLibrary.simpleMessage( "Por favor, note que isso resultará em uma largura de banda maior e uso de bateria até que todos os itens sejam indexados."), @@ -973,11 +977,11 @@ class MessageLookup extends MessageLookupByLibrary { "Modifique sua consulta ou tente procurar por"), "moments": MessageLookupByLibrary.simpleMessage("Momentos"), "monthly": MessageLookupByLibrary.simpleMessage("Mensal"), - "moveItem": m34, + "moveItem": m35, "moveToAlbum": MessageLookupByLibrary.simpleMessage("Mover para álbum"), "moveToHiddenAlbum": MessageLookupByLibrary.simpleMessage("Mover para álbum oculto"), - "movedSuccessfullyTo": m35, + "movedSuccessfullyTo": m36, "movedToTrash": MessageLookupByLibrary.simpleMessage("Movido para a lixeira"), "movingFilesToAlbum": MessageLookupByLibrary.simpleMessage( @@ -1055,7 +1059,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Senha alterada com sucesso"), "passwordLock": MessageLookupByLibrary.simpleMessage("Bloqueio de senha"), - "passwordStrength": m36, + "passwordStrength": m37, "passwordWarning": MessageLookupByLibrary.simpleMessage( "Nós não salvamos essa senha, se você esquecer nós não poderemos descriptografar seus dados"), "paymentDetails": @@ -1064,7 +1068,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Falha no pagamento"), "paymentFailedMessage": MessageLookupByLibrary.simpleMessage( "Infelizmente o seu pagamento falhou. Entre em contato com o suporte e nós ajudaremos você!"), - "paymentFailedTalkToProvider": m37, + "paymentFailedTalkToProvider": m38, "pendingItems": MessageLookupByLibrary.simpleMessage("Itens pendentes"), "pendingSync": MessageLookupByLibrary.simpleMessage("Sincronização pendente"), @@ -1091,7 +1095,7 @@ class MessageLookup extends MessageLookupByLibrary { "pinAlbum": MessageLookupByLibrary.simpleMessage("Fixar álbum"), "playOnTv": MessageLookupByLibrary.simpleMessage("Reproduzir álbum na TV"), - "playStoreFreeTrialValidTill": m38, + "playStoreFreeTrialValidTill": m39, "playstoreSubscription": MessageLookupByLibrary.simpleMessage("Assinatura da PlayStore"), "pleaseCheckYourInternetConnectionAndTryAgain": @@ -1103,12 +1107,12 @@ class MessageLookup extends MessageLookupByLibrary { "pleaseContactSupportIfTheProblemPersists": MessageLookupByLibrary.simpleMessage( "Por favor, contate o suporte se o problema persistir"), - "pleaseEmailUsAt": m39, + "pleaseEmailUsAt": m40, "pleaseGrantPermissions": MessageLookupByLibrary.simpleMessage( "Por favor, conceda as permissões"), "pleaseLoginAgain": MessageLookupByLibrary.simpleMessage( "Por favor, faça login novamente"), - "pleaseSendTheLogsTo": m40, + "pleaseSendTheLogsTo": m41, "pleaseTryAgain": MessageLookupByLibrary.simpleMessage("Por favor, tente novamente"), "pleaseVerifyTheCodeYouHaveEntered": @@ -1145,7 +1149,7 @@ class MessageLookup extends MessageLookupByLibrary { "rateTheApp": MessageLookupByLibrary.simpleMessage("Avalie o aplicativo"), "rateUs": MessageLookupByLibrary.simpleMessage("Avalie-nos"), - "rateUsOnStore": m41, + "rateUsOnStore": m42, "recover": MessageLookupByLibrary.simpleMessage("Recuperar"), "recoverAccount": MessageLookupByLibrary.simpleMessage("Recuperar conta"), @@ -1177,7 +1181,7 @@ class MessageLookup extends MessageLookupByLibrary { "Envie esse código aos seus amigos"), "referralStep2": MessageLookupByLibrary.simpleMessage( "2. Eles se inscreveram para um plano pago"), - "referralStep3": m42, + "referralStep3": m43, "referrals": MessageLookupByLibrary.simpleMessage("Referências"), "referralsAreCurrentlyPaused": MessageLookupByLibrary.simpleMessage( "Referências estão atualmente pausadas"), @@ -1192,6 +1196,8 @@ class MessageLookup extends MessageLookupByLibrary { "remove": MessageLookupByLibrary.simpleMessage("Remover"), "removeDuplicates": MessageLookupByLibrary.simpleMessage("Excluir duplicados"), + "removeDuplicatesDesc": MessageLookupByLibrary.simpleMessage( + "Revise e remova arquivos que sejam duplicatas exatas."), "removeFromAlbum": MessageLookupByLibrary.simpleMessage("Remover do álbum"), "removeFromAlbumTitle": @@ -1201,7 +1207,7 @@ class MessageLookup extends MessageLookupByLibrary { "removeLink": MessageLookupByLibrary.simpleMessage("Remover link"), "removeParticipant": MessageLookupByLibrary.simpleMessage("Remover participante"), - "removeParticipantBody": m43, + "removeParticipantBody": m44, "removePersonLabel": MessageLookupByLibrary.simpleMessage("Remover etiqueta da pessoa"), "removePublicLink": @@ -1217,7 +1223,7 @@ class MessageLookup extends MessageLookupByLibrary { "renameFile": MessageLookupByLibrary.simpleMessage("Renomear arquivo"), "renewSubscription": MessageLookupByLibrary.simpleMessage("Renovar assinatura"), - "renewsOn": m44, + "renewsOn": m45, "reportABug": MessageLookupByLibrary.simpleMessage("Reportar um problema"), "reportBug": @@ -1283,7 +1289,7 @@ class MessageLookup extends MessageLookupByLibrary { "Fotos de grupo que estão sendo tiradas em algum raio da foto"), "searchPeopleEmptySection": MessageLookupByLibrary.simpleMessage( "Convide pessoas e você verá todas as fotos compartilhadas por elas aqui"), - "searchResultCount": m45, + "searchResultCount": m46, "security": MessageLookupByLibrary.simpleMessage("Segurança"), "selectALocation": MessageLookupByLibrary.simpleMessage("Selecionar um local"), @@ -1311,8 +1317,8 @@ class MessageLookup extends MessageLookupByLibrary { "selectedItemsWillBeDeletedFromAllAlbumsAndMoved": MessageLookupByLibrary.simpleMessage( "Os itens selecionados serão excluídos de todos os álbuns e movidos para a lixeira."), - "selectedPhotos": m46, - "selectedPhotosWithYours": m47, + "selectedPhotos": m47, + "selectedPhotosWithYours": m48, "send": MessageLookupByLibrary.simpleMessage("Enviar"), "sendEmail": MessageLookupByLibrary.simpleMessage("Enviar e-mail"), "sendInvite": MessageLookupByLibrary.simpleMessage("Enviar convite"), @@ -1338,16 +1344,16 @@ class MessageLookup extends MessageLookupByLibrary { "shareAnAlbumNow": MessageLookupByLibrary.simpleMessage("Compartilhar um álbum agora"), "shareLink": MessageLookupByLibrary.simpleMessage("Compartilhar link"), - "shareMyVerificationID": m48, + "shareMyVerificationID": m49, "shareOnlyWithThePeopleYouWant": MessageLookupByLibrary.simpleMessage( "Compartilhar apenas com as pessoas que você quiser"), - "shareTextConfirmOthersVerificationID": m49, + "shareTextConfirmOthersVerificationID": m50, "shareTextRecommendUsingEnte": MessageLookupByLibrary.simpleMessage( "Baixe o Ente para que possamos compartilhar facilmente fotos e vídeos de qualidade original\n\nhttps://ente.io"), - "shareTextReferralCode": m50, + "shareTextReferralCode": m51, "shareWithNonenteUsers": MessageLookupByLibrary.simpleMessage( "Compartilhar com usuários não-Ente"), - "shareWithPeopleSectionTitle": m51, + "shareWithPeopleSectionTitle": m52, "shareYourFirstAlbum": MessageLookupByLibrary.simpleMessage( "Compartilhar seu primeiro álbum"), "sharedAlbumSectionDescription": MessageLookupByLibrary.simpleMessage( @@ -1360,7 +1366,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Novas fotos compartilhadas"), "sharedPhotoNotificationsExplanation": MessageLookupByLibrary.simpleMessage( "Receber notificações quando alguém adicionar uma foto em um álbum compartilhado que você faz parte"), - "sharedWith": m52, + "sharedWith": m53, "sharedWithMe": MessageLookupByLibrary.simpleMessage("Compartilhado comigo"), "sharedWithYou": @@ -1376,11 +1382,11 @@ class MessageLookup extends MessageLookupByLibrary { "Encerrar sessão em outros dispositivos"), "signUpTerms": MessageLookupByLibrary.simpleMessage( "Eu concordo com os termos de serviço e a política de privacidade"), - "singleFileDeleteFromDevice": m53, + "singleFileDeleteFromDevice": m54, "singleFileDeleteHighlight": MessageLookupByLibrary.simpleMessage( "Ele será excluído de todos os álbuns."), - "singleFileInBothLocalAndRemote": m54, - "singleFileInRemoteOnly": m55, + "singleFileInBothLocalAndRemote": m55, + "singleFileInRemoteOnly": m56, "skip": MessageLookupByLibrary.simpleMessage("Pular"), "social": MessageLookupByLibrary.simpleMessage("Redes sociais"), "someItemsAreInBothEnteAndYourDevice": @@ -1425,13 +1431,13 @@ class MessageLookup extends MessageLookupByLibrary { "storage": MessageLookupByLibrary.simpleMessage("Armazenamento"), "storageBreakupFamily": MessageLookupByLibrary.simpleMessage("Família"), "storageBreakupYou": MessageLookupByLibrary.simpleMessage("Você"), - "storageInGB": m56, + "storageInGB": m57, "storageLimitExceeded": MessageLookupByLibrary.simpleMessage( "Limite de armazenamento excedido"), - "storageUsageInfo": m57, + "storageUsageInfo": m58, "strongStrength": MessageLookupByLibrary.simpleMessage("Forte"), - "subAlreadyLinkedErrMessage": m58, - "subWillBeCancelledOn": m59, + "subAlreadyLinkedErrMessage": m59, + "subWillBeCancelledOn": m60, "subscribe": MessageLookupByLibrary.simpleMessage("Assinar"), "subscribeToEnableSharing": MessageLookupByLibrary.simpleMessage( "Parece que sua assinatura expirou. Por favor inscreva-se para ativar o compartilhamento."), @@ -1448,7 +1454,7 @@ class MessageLookup extends MessageLookupByLibrary { "suggestFeatures": MessageLookupByLibrary.simpleMessage("Sugerir recurso"), "support": MessageLookupByLibrary.simpleMessage("Suporte"), - "syncProgress": m60, + "syncProgress": m61, "syncStopped": MessageLookupByLibrary.simpleMessage("Sincronização interrompida"), "syncing": MessageLookupByLibrary.simpleMessage("Sincronizando..."), @@ -1475,7 +1481,7 @@ class MessageLookup extends MessageLookupByLibrary { "theseItemsWillBeDeletedFromYourDevice": MessageLookupByLibrary.simpleMessage( "Estes itens serão excluídos do seu dispositivo."), - "theyAlsoGetXGb": m61, + "theyAlsoGetXGb": m62, "theyWillBeDeletedFromAllAlbums": MessageLookupByLibrary.simpleMessage( "Ele será excluído de todos os álbuns."), "thisActionCannotBeUndone": MessageLookupByLibrary.simpleMessage( @@ -1491,7 +1497,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Este e-mail já está em uso"), "thisImageHasNoExifData": MessageLookupByLibrary.simpleMessage( "Esta imagem não tem dados exif"), - "thisIsPersonVerificationId": m62, + "thisIsPersonVerificationId": m63, "thisIsYourVerificationId": MessageLookupByLibrary.simpleMessage( "Este é o seu ID de verificação"), "thisWillLogYouOutOfTheFollowingDevice": @@ -1507,7 +1513,7 @@ class MessageLookup extends MessageLookupByLibrary { "total": MessageLookupByLibrary.simpleMessage("total"), "totalSize": MessageLookupByLibrary.simpleMessage("Tamanho total"), "trash": MessageLookupByLibrary.simpleMessage("Lixeira"), - "trashDaysLeft": m63, + "trashDaysLeft": m64, "tryAgain": MessageLookupByLibrary.simpleMessage("Tente novamente"), "turnOnBackupForAutoUpload": MessageLookupByLibrary.simpleMessage( "Ative o backup para enviar automaticamente arquivos adicionados a esta pasta do dispositivo para o Ente."), @@ -1560,7 +1566,7 @@ class MessageLookup extends MessageLookupByLibrary { "useSelectedPhoto": MessageLookupByLibrary.simpleMessage("Utilizar foto selecionada"), "usedSpace": MessageLookupByLibrary.simpleMessage("Espaço em uso"), - "validTill": m64, + "validTill": m65, "verificationFailedPleaseTryAgain": MessageLookupByLibrary.simpleMessage( "Falha na verificação, por favor, tente novamente"), @@ -1568,7 +1574,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("ID de Verificação"), "verify": MessageLookupByLibrary.simpleMessage("Verificar"), "verifyEmail": MessageLookupByLibrary.simpleMessage("Verificar e-mail"), - "verifyEmailID": m65, + "verifyEmailID": m66, "verifyIDLabel": MessageLookupByLibrary.simpleMessage("Verificar"), "verifyPasskey": MessageLookupByLibrary.simpleMessage("Verificar chave de acesso"), @@ -1586,6 +1592,10 @@ class MessageLookup extends MessageLookupByLibrary { "viewAll": MessageLookupByLibrary.simpleMessage("Ver tudo"), "viewAllExifData": MessageLookupByLibrary.simpleMessage("Ver todos os dados EXIF"), + "viewLargeFiles": + MessageLookupByLibrary.simpleMessage("Arquivos grandes"), + "viewLargeFilesDesc": MessageLookupByLibrary.simpleMessage( + "Ver arquivos que estão consumindo mais espaço de armazenamento"), "viewLogs": MessageLookupByLibrary.simpleMessage("Ver logs"), "viewRecoveryKey": MessageLookupByLibrary.simpleMessage("Ver chave de recuperação"), @@ -1601,12 +1611,12 @@ class MessageLookup extends MessageLookupByLibrary { "weDontSupportEditingPhotosAndAlbumsThatYouDont": MessageLookupByLibrary.simpleMessage( "Não suportamos a edição de fotos e álbuns que você ainda não possui"), - "weHaveSendEmailTo": m66, + "weHaveSendEmailTo": m67, "weakStrength": MessageLookupByLibrary.simpleMessage("Fraca"), "welcomeBack": MessageLookupByLibrary.simpleMessage("Bem-vindo de volta!"), "yearly": MessageLookupByLibrary.simpleMessage("Anual"), - "yearsAgo": m67, + "yearsAgo": m68, "yes": MessageLookupByLibrary.simpleMessage("Sim"), "yesCancel": MessageLookupByLibrary.simpleMessage("Sim, cancelar"), "yesConvertToViewer": MessageLookupByLibrary.simpleMessage( @@ -1637,7 +1647,7 @@ class MessageLookup extends MessageLookupByLibrary { "Você não pode compartilhar consigo mesmo"), "youDontHaveAnyArchivedItems": MessageLookupByLibrary.simpleMessage( "Você não tem nenhum item arquivado."), - "youHaveSuccessfullyFreedUp": m68, + "youHaveSuccessfullyFreedUp": m69, "yourAccountHasBeenDeleted": MessageLookupByLibrary.simpleMessage("Sua conta foi excluída"), "yourMap": MessageLookupByLibrary.simpleMessage("Seu mapa"), diff --git a/mobile/lib/generated/intl/messages_zh.dart b/mobile/lib/generated/intl/messages_zh.dart index 8f2fc68a3a..e3f16ba156 100644 --- a/mobile/lib/generated/intl/messages_zh.dart +++ b/mobile/lib/generated/intl/messages_zh.dart @@ -41,160 +41,160 @@ class MessageLookup extends MessageLookupByLibrary { static String m7(versionValue) => "版本: ${versionValue}"; - static String m8(paymentProvider) => "请先取消您现有的订阅 ${paymentProvider}"; + static String m8(freeAmount, storageUnit) => + "${freeAmount} ${storageUnit} 空闲"; - static String m9(user) => "${user} 将无法添加更多照片到此相册\n\n他们仍然能够删除他们添加的现有照片"; + static String m9(paymentProvider) => "请先取消您现有的订阅 ${paymentProvider}"; - static String m10(isFamilyMember, storageAmountInGb) => + static String m10(user) => "${user} 将无法添加更多照片到此相册\n\n他们仍然能够删除他们添加的现有照片"; + + static String m11(isFamilyMember, storageAmountInGb) => "${Intl.select(isFamilyMember, { 'true': '到目前为止,您的家庭已经领取了 ${storageAmountInGb} GB', 'false': '到目前为止,您已经领取了 ${storageAmountInGb} GB', 'other': '到目前为止,您已经领取了${storageAmountInGb} GB', })}"; - static String m11(albumName) => "为 ${albumName} 创建了协作链接"; + static String m12(albumName) => "为 ${albumName} 创建了协作链接"; - static String m12(familyAdminEmail) => + static String m13(familyAdminEmail) => "请联系 ${familyAdminEmail} 来管理您的订阅"; - static String m13(provider) => + static String m14(provider) => "请通过support@ente.io 用英语联系我们来管理您的 ${provider} 订阅。"; - static String m69(endpoint) => "已连接至 ${endpoint}"; + static String m15(endpoint) => "已连接至 ${endpoint}"; - static String m14(count) => + static String m16(count) => "${Intl.plural(count, one: '删除 ${count} 个项目', other: '删除 ${count} 个项目')}"; - static String m15(currentlyDeleting, totalCount) => + static String m17(currentlyDeleting, totalCount) => "正在删除 ${currentlyDeleting} /共 ${totalCount}"; - static String m16(albumName) => "这将删除用于访问\"${albumName}\"的公开链接。"; + static String m18(albumName) => "这将删除用于访问\"${albumName}\"的公开链接。"; - static String m17(supportEmail) => "请从您注册的邮箱发送一封邮件到 ${supportEmail}"; + static String m19(supportEmail) => "请从您注册的邮箱发送一封邮件到 ${supportEmail}"; - static String m18(count, storageSaved) => + static String m20(count, storageSaved) => "您已经清理了 ${Intl.plural(count, other: '${count} 个重复文件')}, 释放了 (${storageSaved}!)"; - static String m19(count, formattedSize) => + static String m21(count, formattedSize) => "${count} 个文件,每个文件 ${formattedSize}"; - static String m20(newEmail) => "电子邮件已更改为 ${newEmail}"; + static String m22(newEmail) => "电子邮件已更改为 ${newEmail}"; - static String m21(email) => "${email} 没有 Ente 帐户。\n\n向他们发出共享照片的邀请。"; + static String m23(email) => "${email} 没有 Ente 帐户。\n\n向他们发出共享照片的邀请。"; - static String m22(count, formattedNumber) => + static String m24(count, formattedNumber) => "此设备上的 ${Intl.plural(count, one: '1 个文件', other: '${formattedNumber} 个文件')} 已安全备份"; - static String m23(count, formattedNumber) => + static String m25(count, formattedNumber) => "此相册中的 ${Intl.plural(count, one: '1 个文件', other: '${formattedNumber} 个文件')} 已安全备份"; - static String m24(storageAmountInGB) => + static String m26(storageAmountInGB) => "每当有人使用您的代码注册付费计划时您将获得${storageAmountInGB} GB"; - static String m25(freeAmount, storageUnit) => - "${freeAmount} ${storageUnit} 空闲"; + static String m27(endDate) => "免费试用有效期至 ${endDate}"; - static String m26(endDate) => "免费试用有效期至 ${endDate}"; - - static String m27(count) => + static String m28(count) => "只要您有有效的订阅,您仍然可以在 Ente 上访问 ${Intl.plural(count, one: '它', other: '它们')}"; - static String m28(sizeInMBorGB) => "释放 ${sizeInMBorGB}"; + static String m29(sizeInMBorGB) => "释放 ${sizeInMBorGB}"; - static String m29(count, formattedSize) => + static String m30(count, formattedSize) => "${Intl.plural(count, one: '它可以从设备中删除以释放 ${formattedSize}', other: '它们可以从设备中删除以释放 ${formattedSize}')}"; - static String m30(currentlyProcessing, totalCount) => + static String m31(currentlyProcessing, totalCount) => "正在处理 ${currentlyProcessing} / ${totalCount}"; - static String m31(count) => + static String m32(count) => "${Intl.plural(count, one: '${count} 个项目', other: '${count} 个项目')}"; - static String m32(expiryTime) => "链接将在 ${expiryTime} 过期"; + static String m33(expiryTime) => "链接将在 ${expiryTime} 过期"; - static String m33(count, formattedCount) => + static String m34(count, formattedCount) => "${Intl.plural(count, zero: '没有回忆', one: '${formattedCount} 个回忆', other: '${formattedCount} 个回忆')}"; - static String m34(count) => + static String m35(count) => "${Intl.plural(count, one: '移动一个项目', other: '移动一些项目')}"; - static String m35(albumName) => "成功移动到 ${albumName}"; + static String m36(albumName) => "成功移动到 ${albumName}"; - static String m36(passwordStrengthValue) => "密码强度: ${passwordStrengthValue}"; + static String m37(passwordStrengthValue) => "密码强度: ${passwordStrengthValue}"; - static String m37(providerName) => "如果您被收取费用,请用英语与 ${providerName} 的客服聊天"; + static String m38(providerName) => "如果您被收取费用,请用英语与 ${providerName} 的客服聊天"; - static String m38(endDate) => "免费试用有效期至 ${endDate}。\n在此之后您可以选择付费计划。"; + static String m39(endDate) => "免费试用有效期至 ${endDate}。\n在此之后您可以选择付费计划。"; - static String m39(toEmail) => "请给我们发送电子邮件至 ${toEmail}"; + static String m40(toEmail) => "请给我们发送电子邮件至 ${toEmail}"; - static String m40(toEmail) => "请将日志发送至 \n${toEmail}"; + static String m41(toEmail) => "请将日志发送至 \n${toEmail}"; - static String m41(storeName) => "在 ${storeName} 上给我们评分"; + static String m42(storeName) => "在 ${storeName} 上给我们评分"; - static String m42(storageInGB) => "3. 你和朋友都将免费获得 ${storageInGB} GB*"; + static String m43(storageInGB) => "3. 你和朋友都将免费获得 ${storageInGB} GB*"; - static String m43(userEmail) => + static String m44(userEmail) => "${userEmail} 将从这个共享相册中删除\n\nTA们添加的任何照片也将从相册中删除"; - static String m44(endDate) => "在 ${endDate} 前续费"; + static String m45(endDate) => "在 ${endDate} 前续费"; - static String m45(count) => + static String m46(count) => "${Intl.plural(count, other: '已找到 ${count} 个结果')}"; - static String m46(count) => "已选择 ${count} 个"; + static String m47(count) => "已选择 ${count} 个"; - static String m47(count, yourCount) => "选择了 ${count} 个 (您的 ${yourCount} 个)"; + static String m48(count, yourCount) => "选择了 ${count} 个 (您的 ${yourCount} 个)"; - static String m48(verificationID) => "这是我的ente.io 的验证 ID: ${verificationID}。"; + static String m49(verificationID) => "这是我的ente.io 的验证 ID: ${verificationID}。"; - static String m49(verificationID) => + static String m50(verificationID) => "嘿,你能确认这是你的 ente.io 验证 ID吗:${verificationID}"; - static String m50(referralCode, referralStorageInGB) => + static String m51(referralCode, referralStorageInGB) => "Ente 推荐代码:${referralCode}\n\n在 \"设置\"→\"通用\"→\"推荐 \"中应用它,即可在注册付费计划后免费获得 ${referralStorageInGB} GB 存储空间\n\nhttps://ente.io"; - static String m51(numberOfPeople) => + static String m52(numberOfPeople) => "${Intl.plural(numberOfPeople, zero: '与特定人员共享', one: '与 1 人共享', other: '与 ${numberOfPeople} 人共享')}"; - static String m52(emailIDs) => "与 ${emailIDs} 共享"; + static String m53(emailIDs) => "与 ${emailIDs} 共享"; - static String m53(fileType) => "此 ${fileType} 将从您的设备中删除。"; + static String m54(fileType) => "此 ${fileType} 将从您的设备中删除。"; - static String m54(fileType) => "${fileType} 已同时存在于 Ente 和您的设备中。"; + static String m55(fileType) => "${fileType} 已同时存在于 Ente 和您的设备中。"; - static String m55(fileType) => "${fileType} 将从 Ente 中删除。"; + static String m56(fileType) => "${fileType} 将从 Ente 中删除。"; - static String m56(storageAmountInGB) => "${storageAmountInGB} GB"; + static String m57(storageAmountInGB) => "${storageAmountInGB} GB"; - static String m57( + static String m58( usedAmount, usedStorageUnit, totalAmount, totalStorageUnit) => "已使用 ${usedAmount} ${usedStorageUnit} / ${totalAmount} ${totalStorageUnit}"; - static String m58(id) => + static String m59(id) => "您的 ${id} 已链接到另一个 Ente 账户。\n如果您想在此账户中使用您的 ${id} ,请联系我们的支持人员"; - static String m59(endDate) => "您的订阅将于 ${endDate} 取消"; + static String m60(endDate) => "您的订阅将于 ${endDate} 取消"; - static String m60(completed, total) => "已保存的回忆 ${completed}/共 ${total}"; + static String m61(completed, total) => "已保存的回忆 ${completed}/共 ${total}"; - static String m61(storageAmountInGB) => "他们也会获得 ${storageAmountInGB} GB"; + static String m62(storageAmountInGB) => "他们也会获得 ${storageAmountInGB} GB"; - static String m62(email) => "这是 ${email} 的验证ID"; + static String m63(email) => "这是 ${email} 的验证ID"; - static String m63(count) => + static String m64(count) => "${Intl.plural(count, zero: '', one: '1天', other: '${count} 天')}"; - static String m64(endDate) => "有效期至 ${endDate}"; + static String m65(endDate) => "有效期至 ${endDate}"; - static String m65(email) => "验证 ${email}"; + static String m66(email) => "验证 ${email}"; - static String m66(email) => "我们已经发送邮件到 ${email}"; + static String m67(email) => "我们已经发送邮件到 ${email}"; - static String m67(count) => + static String m68(count) => "${Intl.plural(count, one: '${count} 年前', other: '${count} 年前')}"; - static String m68(storageSaved) => "您已成功释放了 ${storageSaved}!"; + static String m69(storageSaved) => "您已成功释放了 ${storageSaved}!"; final messages = _notInlinedMessages(_notInlinedMessages); static Map _notInlinedMessages(_) => { @@ -325,10 +325,13 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("您将在此处看到可用的 Cast 设备。"), "autoCastiOSPermission": MessageLookupByLibrary.simpleMessage( "请确保已在“设置”中为 Ente Photos 应用打开本地网络权限。"), + "autoLogoutMessage": MessageLookupByLibrary.simpleMessage( + "由于技术故障,您已退出登录。对于由此造成的不便,我们深表歉意。"), "autoPair": MessageLookupByLibrary.simpleMessage("自动配对"), "autoPairDesc": MessageLookupByLibrary.simpleMessage("自动配对仅适用于支持 Chromecast 的设备。"), "available": MessageLookupByLibrary.simpleMessage("可用"), + "availableStorageSpace": m8, "backedUpFolders": MessageLookupByLibrary.simpleMessage("已备份的文件夹"), "backup": MessageLookupByLibrary.simpleMessage("备份"), "backupFailed": MessageLookupByLibrary.simpleMessage("备份失败"), @@ -347,9 +350,9 @@ class MessageLookup extends MessageLookupByLibrary { "canOnlyRemoveFilesOwnedByYou": MessageLookupByLibrary.simpleMessage("只能删除您拥有的文件"), "cancel": MessageLookupByLibrary.simpleMessage("取消"), - "cancelOtherSubscription": m8, + "cancelOtherSubscription": m9, "cancelSubscription": MessageLookupByLibrary.simpleMessage("取消订阅"), - "cannotAddMorePhotosAfterBecomingViewer": m9, + "cannotAddMorePhotosAfterBecomingViewer": m10, "cannotDeleteSharedFiles": MessageLookupByLibrary.simpleMessage("无法删除共享文件"), "castIPMismatchBody": @@ -371,7 +374,7 @@ class MessageLookup extends MessageLookupByLibrary { "claimFreeStorage": MessageLookupByLibrary.simpleMessage("领取免费存储"), "claimMore": MessageLookupByLibrary.simpleMessage("领取更多!"), "claimed": MessageLookupByLibrary.simpleMessage("已领取"), - "claimedStorageSoFar": m10, + "claimedStorageSoFar": m11, "cleanUncategorized": MessageLookupByLibrary.simpleMessage("清除未分类的"), "cleanUncategorizedDescription": MessageLookupByLibrary.simpleMessage("从“未分类”中删除其他相册中存在的所有文件"), @@ -391,7 +394,7 @@ class MessageLookup extends MessageLookupByLibrary { "collabLinkSectionDescription": MessageLookupByLibrary.simpleMessage( "创建一个链接来让他人无需 Ente 应用程序或账户即可在您的共享相册中添加和查看照片。非常适合收集活动照片。"), "collaborativeLink": MessageLookupByLibrary.simpleMessage("协作链接"), - "collaborativeLinkCreatedFor": m11, + "collaborativeLinkCreatedFor": m12, "collaborator": MessageLookupByLibrary.simpleMessage("协作者"), "collaboratorsCanAddPhotosAndVideosToTheSharedAlbum": MessageLookupByLibrary.simpleMessage("协作者可以将照片和视频添加到共享相册中。"), @@ -413,9 +416,9 @@ class MessageLookup extends MessageLookupByLibrary { "confirmYourRecoveryKey": MessageLookupByLibrary.simpleMessage("确认您的恢复密钥"), "connectToDevice": MessageLookupByLibrary.simpleMessage("连接到设备"), - "contactFamilyAdmin": m12, + "contactFamilyAdmin": m13, "contactSupport": MessageLookupByLibrary.simpleMessage("联系支持"), - "contactToManageSubscription": m13, + "contactToManageSubscription": m14, "contacts": MessageLookupByLibrary.simpleMessage("联系人"), "contents": MessageLookupByLibrary.simpleMessage("内容"), "continueLabel": MessageLookupByLibrary.simpleMessage("继续"), @@ -447,7 +450,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("可用的关键更新"), "currentUsageIs": MessageLookupByLibrary.simpleMessage("当前用量 "), "custom": MessageLookupByLibrary.simpleMessage("自定义"), - "customEndpoint": m69, + "customEndpoint": m15, "darkTheme": MessageLookupByLibrary.simpleMessage("深色"), "dayToday": MessageLookupByLibrary.simpleMessage("今天"), "dayYesterday": MessageLookupByLibrary.simpleMessage("昨天"), @@ -476,10 +479,10 @@ class MessageLookup extends MessageLookupByLibrary { "deleteFromBoth": MessageLookupByLibrary.simpleMessage("同时从两者中删除"), "deleteFromDevice": MessageLookupByLibrary.simpleMessage("从设备中删除"), "deleteFromEnte": MessageLookupByLibrary.simpleMessage("从 Ente 中删除"), - "deleteItemCount": m14, + "deleteItemCount": m16, "deleteLocation": MessageLookupByLibrary.simpleMessage("删除位置"), "deletePhotos": MessageLookupByLibrary.simpleMessage("删除照片"), - "deleteProgress": m15, + "deleteProgress": m17, "deleteReason1": MessageLookupByLibrary.simpleMessage("找不到我想要的功能"), "deleteReason2": MessageLookupByLibrary.simpleMessage("应用或某个功能没有按我的预期运行"), @@ -510,7 +513,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("查看者仍然可以使用外部工具截图或保存您的照片副本"), "disableDownloadWarningTitle": MessageLookupByLibrary.simpleMessage("请注意"), - "disableLinkMessage": m16, + "disableLinkMessage": m18, "disableTwofactor": MessageLookupByLibrary.simpleMessage("禁用双重认证"), "disablingTwofactorAuthentication": MessageLookupByLibrary.simpleMessage("正在禁用双重认证..."), @@ -527,9 +530,9 @@ class MessageLookup extends MessageLookupByLibrary { "download": MessageLookupByLibrary.simpleMessage("下载"), "downloadFailed": MessageLookupByLibrary.simpleMessage("下載失敗"), "downloading": MessageLookupByLibrary.simpleMessage("正在下载..."), - "dropSupportEmail": m17, - "duplicateFileCountWithStorageSaved": m18, - "duplicateItemsGroup": m19, + "dropSupportEmail": m19, + "duplicateFileCountWithStorageSaved": m20, + "duplicateItemsGroup": m21, "edit": MessageLookupByLibrary.simpleMessage("编辑"), "editLocation": MessageLookupByLibrary.simpleMessage("编辑位置"), "editLocationTagTitle": MessageLookupByLibrary.simpleMessage("编辑位置"), @@ -538,8 +541,8 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("对位置的编辑只能在 Ente 内看到"), "eligible": MessageLookupByLibrary.simpleMessage("符合资格"), "email": MessageLookupByLibrary.simpleMessage("电子邮件地址"), - "emailChangedTo": m20, - "emailNoEnteAccount": m21, + "emailChangedTo": m22, + "emailNoEnteAccount": m23, "emailVerificationToggle": MessageLookupByLibrary.simpleMessage("电子邮件验证"), "emailYourLogs": MessageLookupByLibrary.simpleMessage("通过电子邮件发送您的日志"), @@ -620,8 +623,8 @@ class MessageLookup extends MessageLookupByLibrary { "fileSavedToGallery": MessageLookupByLibrary.simpleMessage("文件已保存到相册"), "fileTypes": MessageLookupByLibrary.simpleMessage("文件类型"), "fileTypesAndNames": MessageLookupByLibrary.simpleMessage("文件类型和名称"), - "filesBackedUpFromDevice": m22, - "filesBackedUpInAlbum": m23, + "filesBackedUpFromDevice": m24, + "filesBackedUpInAlbum": m25, "filesDeleted": MessageLookupByLibrary.simpleMessage("文件已删除"), "filesSavedToGallery": MessageLookupByLibrary.simpleMessage("多个文件已保存到相册"), @@ -631,22 +634,23 @@ class MessageLookup extends MessageLookupByLibrary { "forgotPassword": MessageLookupByLibrary.simpleMessage("忘记密码"), "foundFaces": MessageLookupByLibrary.simpleMessage("已找到的人脸"), "freeStorageClaimed": MessageLookupByLibrary.simpleMessage("已领取的免费存储"), - "freeStorageOnReferralSuccess": m24, - "freeStorageSpace": m25, + "freeStorageOnReferralSuccess": m26, "freeStorageUsable": MessageLookupByLibrary.simpleMessage("可用的免费存储"), "freeTrial": MessageLookupByLibrary.simpleMessage("免费试用"), - "freeTrialValidTill": m26, - "freeUpAccessPostDelete": m27, - "freeUpAmount": m28, + "freeTrialValidTill": m27, + "freeUpAccessPostDelete": m28, + "freeUpAmount": m29, "freeUpDeviceSpace": MessageLookupByLibrary.simpleMessage("释放设备空间"), + "freeUpDeviceSpaceDesc": + MessageLookupByLibrary.simpleMessage("通过清除已备份的文件来节省设备空间。"), "freeUpSpace": MessageLookupByLibrary.simpleMessage("释放空间"), - "freeUpSpaceSaving": m29, + "freeUpSpaceSaving": m30, "galleryMemoryLimitInfo": MessageLookupByLibrary.simpleMessage("在图库中显示最多1000个回忆"), "general": MessageLookupByLibrary.simpleMessage("通用"), "generatingEncryptionKeys": MessageLookupByLibrary.simpleMessage("正在生成加密密钥..."), - "genericProgress": m30, + "genericProgress": m31, "goToSettings": MessageLookupByLibrary.simpleMessage("前往设置"), "googlePlayId": MessageLookupByLibrary.simpleMessage("Google Play ID"), "grantFullAccessPrompt": @@ -703,7 +707,7 @@ class MessageLookup extends MessageLookupByLibrary { "itLooksLikeSomethingWentWrongPleaseRetryAfterSome": MessageLookupByLibrary.simpleMessage( "看起来出了点问题。 请稍后重试。 如果错误仍然存在,请联系我们的支持团队。"), - "itemCount": m31, + "itemCount": m32, "itemsShowTheNumberOfDaysRemainingBeforePermanentDeletion": MessageLookupByLibrary.simpleMessage("项目显示永久删除前剩余的天数"), "itemsWillBeRemovedFromAlbum": @@ -726,7 +730,7 @@ class MessageLookup extends MessageLookupByLibrary { "linkDeviceLimit": MessageLookupByLibrary.simpleMessage("设备限制"), "linkEnabled": MessageLookupByLibrary.simpleMessage("已启用"), "linkExpired": MessageLookupByLibrary.simpleMessage("已过期"), - "linkExpiresOn": m32, + "linkExpiresOn": m33, "linkExpiry": MessageLookupByLibrary.simpleMessage("链接过期"), "linkHasExpired": MessageLookupByLibrary.simpleMessage("链接已过期"), "linkNeverExpires": MessageLookupByLibrary.simpleMessage("永不"), @@ -788,7 +792,7 @@ class MessageLookup extends MessageLookupByLibrary { "maps": MessageLookupByLibrary.simpleMessage("地图"), "mastodon": MessageLookupByLibrary.simpleMessage("Mastodon"), "matrix": MessageLookupByLibrary.simpleMessage("Matrix"), - "memoryCount": m33, + "memoryCount": m34, "merchandise": MessageLookupByLibrary.simpleMessage("商品"), "mlIndexingDescription": MessageLookupByLibrary.simpleMessage( "请注意,机器学习将使用更高的带宽和更多的电量,直到所有项目都被索引为止。"), @@ -799,10 +803,10 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("修改您的查询,或尝试搜索"), "moments": MessageLookupByLibrary.simpleMessage("瞬间"), "monthly": MessageLookupByLibrary.simpleMessage("每月"), - "moveItem": m34, + "moveItem": m35, "moveToAlbum": MessageLookupByLibrary.simpleMessage("移动到相册"), "moveToHiddenAlbum": MessageLookupByLibrary.simpleMessage("移至隐藏相册"), - "movedSuccessfullyTo": m35, + "movedSuccessfullyTo": m36, "movedToTrash": MessageLookupByLibrary.simpleMessage("已移至回收站"), "movingFilesToAlbum": MessageLookupByLibrary.simpleMessage("正在将文件移动到相册..."), @@ -867,14 +871,14 @@ class MessageLookup extends MessageLookupByLibrary { "passwordChangedSuccessfully": MessageLookupByLibrary.simpleMessage("密码修改成功"), "passwordLock": MessageLookupByLibrary.simpleMessage("密码锁"), - "passwordStrength": m36, + "passwordStrength": m37, "passwordWarning": MessageLookupByLibrary.simpleMessage( "我们不储存这个密码,所以如果忘记, 我们将无法解密您的数据"), "paymentDetails": MessageLookupByLibrary.simpleMessage("付款明细"), "paymentFailed": MessageLookupByLibrary.simpleMessage("支付失败"), "paymentFailedMessage": MessageLookupByLibrary.simpleMessage( "不幸的是,您的付款失败。请联系支持人员,我们将为您提供帮助!"), - "paymentFailedTalkToProvider": m37, + "paymentFailedTalkToProvider": m38, "pendingItems": MessageLookupByLibrary.simpleMessage("待处理项目"), "pendingSync": MessageLookupByLibrary.simpleMessage("正在等待同步"), "people": MessageLookupByLibrary.simpleMessage("人物"), @@ -893,7 +897,7 @@ class MessageLookup extends MessageLookupByLibrary { "pickCenterPoint": MessageLookupByLibrary.simpleMessage("选择中心点"), "pinAlbum": MessageLookupByLibrary.simpleMessage("置顶相册"), "playOnTv": MessageLookupByLibrary.simpleMessage("在电视上播放相册"), - "playStoreFreeTrialValidTill": m38, + "playStoreFreeTrialValidTill": m39, "playstoreSubscription": MessageLookupByLibrary.simpleMessage("PlayStore 订阅"), "pleaseCheckYourInternetConnectionAndTryAgain": @@ -903,10 +907,10 @@ class MessageLookup extends MessageLookupByLibrary { "请用英语联系 support@ente.io ,我们将乐意提供帮助!"), "pleaseContactSupportIfTheProblemPersists": MessageLookupByLibrary.simpleMessage("如果问题仍然存在,请联系支持"), - "pleaseEmailUsAt": m39, + "pleaseEmailUsAt": m40, "pleaseGrantPermissions": MessageLookupByLibrary.simpleMessage("请授予权限"), "pleaseLoginAgain": MessageLookupByLibrary.simpleMessage("请重新登录"), - "pleaseSendTheLogsTo": m40, + "pleaseSendTheLogsTo": m41, "pleaseTryAgain": MessageLookupByLibrary.simpleMessage("请重试"), "pleaseVerifyTheCodeYouHaveEntered": MessageLookupByLibrary.simpleMessage("请验证您输入的代码"), @@ -932,7 +936,7 @@ class MessageLookup extends MessageLookupByLibrary { "raiseTicket": MessageLookupByLibrary.simpleMessage("提升工单"), "rateTheApp": MessageLookupByLibrary.simpleMessage("为此应用评分"), "rateUs": MessageLookupByLibrary.simpleMessage("给我们评分"), - "rateUsOnStore": m41, + "rateUsOnStore": m42, "recover": MessageLookupByLibrary.simpleMessage("恢复"), "recoverAccount": MessageLookupByLibrary.simpleMessage("恢复账户"), "recoverButton": MessageLookupByLibrary.simpleMessage("恢复"), @@ -957,7 +961,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("把我们推荐给你的朋友然后获得延长一倍的订阅计划"), "referralStep1": MessageLookupByLibrary.simpleMessage("1. 将此代码提供给您的朋友"), "referralStep2": MessageLookupByLibrary.simpleMessage("2. 他们注册一个付费计划"), - "referralStep3": m42, + "referralStep3": m43, "referrals": MessageLookupByLibrary.simpleMessage("推荐"), "referralsAreCurrentlyPaused": MessageLookupByLibrary.simpleMessage("推荐已暂停"), @@ -970,13 +974,15 @@ class MessageLookup extends MessageLookupByLibrary { "remoteVideos": MessageLookupByLibrary.simpleMessage("云端视频"), "remove": MessageLookupByLibrary.simpleMessage("移除"), "removeDuplicates": MessageLookupByLibrary.simpleMessage("移除重复内容"), + "removeDuplicatesDesc": + MessageLookupByLibrary.simpleMessage("检查并删除完全重复的文件。"), "removeFromAlbum": MessageLookupByLibrary.simpleMessage("从相册中移除"), "removeFromAlbumTitle": MessageLookupByLibrary.simpleMessage("要从相册中移除吗?"), "removeFromFavorite": MessageLookupByLibrary.simpleMessage("从收藏中移除"), "removeLink": MessageLookupByLibrary.simpleMessage("移除链接"), "removeParticipant": MessageLookupByLibrary.simpleMessage("移除参与者"), - "removeParticipantBody": m43, + "removeParticipantBody": m44, "removePersonLabel": MessageLookupByLibrary.simpleMessage("移除人物标签"), "removePublicLink": MessageLookupByLibrary.simpleMessage("删除公开链接"), "removeShareItemsWarning": @@ -988,7 +994,7 @@ class MessageLookup extends MessageLookupByLibrary { "renameAlbum": MessageLookupByLibrary.simpleMessage("重命名相册"), "renameFile": MessageLookupByLibrary.simpleMessage("重命名文件"), "renewSubscription": MessageLookupByLibrary.simpleMessage("续费订阅"), - "renewsOn": m44, + "renewsOn": m45, "reportABug": MessageLookupByLibrary.simpleMessage("报告错误"), "reportBug": MessageLookupByLibrary.simpleMessage("报告错误"), "resendEmail": MessageLookupByLibrary.simpleMessage("重新发送电子邮件"), @@ -1036,7 +1042,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("在照片的一定半径内拍摄的几组照片"), "searchPeopleEmptySection": MessageLookupByLibrary.simpleMessage("邀请他人,您将在此看到他们分享的所有照片"), - "searchResultCount": m45, + "searchResultCount": m46, "security": MessageLookupByLibrary.simpleMessage("安全"), "selectALocation": MessageLookupByLibrary.simpleMessage("选择一个位置"), "selectALocationFirst": @@ -1056,8 +1062,8 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("所选文件夹将被加密并备份"), "selectedItemsWillBeDeletedFromAllAlbumsAndMoved": MessageLookupByLibrary.simpleMessage("所选项目将从所有相册中删除并移动到回收站。"), - "selectedPhotos": m46, - "selectedPhotosWithYours": m47, + "selectedPhotos": m47, + "selectedPhotosWithYours": m48, "send": MessageLookupByLibrary.simpleMessage("发送"), "sendEmail": MessageLookupByLibrary.simpleMessage("发送电子邮件"), "sendInvite": MessageLookupByLibrary.simpleMessage("发送邀请"), @@ -1077,16 +1083,16 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("打开相册并点击右上角的分享按钮进行分享"), "shareAnAlbumNow": MessageLookupByLibrary.simpleMessage("立即分享相册"), "shareLink": MessageLookupByLibrary.simpleMessage("分享链接"), - "shareMyVerificationID": m48, + "shareMyVerificationID": m49, "shareOnlyWithThePeopleYouWant": MessageLookupByLibrary.simpleMessage("仅与您想要的人分享"), - "shareTextConfirmOthersVerificationID": m49, + "shareTextConfirmOthersVerificationID": m50, "shareTextRecommendUsingEnte": MessageLookupByLibrary.simpleMessage("下载 Ente,让我们轻松共享高质量的原始照片和视频"), - "shareTextReferralCode": m50, + "shareTextReferralCode": m51, "shareWithNonenteUsers": MessageLookupByLibrary.simpleMessage("与非 Ente 用户共享"), - "shareWithPeopleSectionTitle": m51, + "shareWithPeopleSectionTitle": m52, "shareYourFirstAlbum": MessageLookupByLibrary.simpleMessage("分享您的第一个相册"), "sharedAlbumSectionDescription": MessageLookupByLibrary.simpleMessage( @@ -1097,7 +1103,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("新共享的照片"), "sharedPhotoNotificationsExplanation": MessageLookupByLibrary.simpleMessage("当有人将照片添加到您所属的共享相册时收到通知"), - "sharedWith": m52, + "sharedWith": m53, "sharedWithMe": MessageLookupByLibrary.simpleMessage("与我共享"), "sharedWithYou": MessageLookupByLibrary.simpleMessage("已与您共享"), "sharing": MessageLookupByLibrary.simpleMessage("正在分享..."), @@ -1109,11 +1115,11 @@ class MessageLookup extends MessageLookupByLibrary { "signOutOtherDevices": MessageLookupByLibrary.simpleMessage("登出其他设备"), "signUpTerms": MessageLookupByLibrary.simpleMessage( "我同意 服务条款隐私政策"), - "singleFileDeleteFromDevice": m53, + "singleFileDeleteFromDevice": m54, "singleFileDeleteHighlight": MessageLookupByLibrary.simpleMessage("它将从所有相册中删除。"), - "singleFileInBothLocalAndRemote": m54, - "singleFileInRemoteOnly": m55, + "singleFileInBothLocalAndRemote": m55, + "singleFileInRemoteOnly": m56, "skip": MessageLookupByLibrary.simpleMessage("跳过"), "social": MessageLookupByLibrary.simpleMessage("社交"), "someItemsAreInBothEnteAndYourDevice": @@ -1146,12 +1152,12 @@ class MessageLookup extends MessageLookupByLibrary { "storage": MessageLookupByLibrary.simpleMessage("存储空间"), "storageBreakupFamily": MessageLookupByLibrary.simpleMessage("家庭"), "storageBreakupYou": MessageLookupByLibrary.simpleMessage("您"), - "storageInGB": m56, + "storageInGB": m57, "storageLimitExceeded": MessageLookupByLibrary.simpleMessage("已超出存储限制"), - "storageUsageInfo": m57, + "storageUsageInfo": m58, "strongStrength": MessageLookupByLibrary.simpleMessage("强"), - "subAlreadyLinkedErrMessage": m58, - "subWillBeCancelledOn": m59, + "subAlreadyLinkedErrMessage": m59, + "subWillBeCancelledOn": m60, "subscribe": MessageLookupByLibrary.simpleMessage("订阅"), "subscribeToEnableSharing": MessageLookupByLibrary.simpleMessage("您的订阅似乎已过期。请订阅以启用分享。"), @@ -1164,7 +1170,7 @@ class MessageLookup extends MessageLookupByLibrary { "successfullyUnhid": MessageLookupByLibrary.simpleMessage("已成功取消隐藏"), "suggestFeatures": MessageLookupByLibrary.simpleMessage("建议新功能"), "support": MessageLookupByLibrary.simpleMessage("支持"), - "syncProgress": m60, + "syncProgress": m61, "syncStopped": MessageLookupByLibrary.simpleMessage("同步已停止"), "syncing": MessageLookupByLibrary.simpleMessage("正在同步···"), "systemTheme": MessageLookupByLibrary.simpleMessage("适应系统"), @@ -1187,7 +1193,7 @@ class MessageLookup extends MessageLookupByLibrary { "theme": MessageLookupByLibrary.simpleMessage("主题"), "theseItemsWillBeDeletedFromYourDevice": MessageLookupByLibrary.simpleMessage("这些项目将从您的设备中删除。"), - "theyAlsoGetXGb": m61, + "theyAlsoGetXGb": m62, "theyWillBeDeletedFromAllAlbums": MessageLookupByLibrary.simpleMessage("他们将从所有相册中删除。"), "thisActionCannotBeUndone": @@ -1201,7 +1207,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("这个邮箱地址已经被使用"), "thisImageHasNoExifData": MessageLookupByLibrary.simpleMessage("此图像没有Exif 数据"), - "thisIsPersonVerificationId": m62, + "thisIsPersonVerificationId": m63, "thisIsYourVerificationId": MessageLookupByLibrary.simpleMessage("这是您的验证 ID"), "thisWillLogYouOutOfTheFollowingDevice": @@ -1215,7 +1221,7 @@ class MessageLookup extends MessageLookupByLibrary { "total": MessageLookupByLibrary.simpleMessage("总计"), "totalSize": MessageLookupByLibrary.simpleMessage("总大小"), "trash": MessageLookupByLibrary.simpleMessage("回收站"), - "trashDaysLeft": m63, + "trashDaysLeft": m64, "tryAgain": MessageLookupByLibrary.simpleMessage("请再试一次"), "turnOnBackupForAutoUpload": MessageLookupByLibrary.simpleMessage( "打开备份可自动上传添加到此设备文件夹的文件至 Ente。"), @@ -1258,13 +1264,13 @@ class MessageLookup extends MessageLookupByLibrary { "useRecoveryKey": MessageLookupByLibrary.simpleMessage("使用恢复密钥"), "useSelectedPhoto": MessageLookupByLibrary.simpleMessage("使用所选照片"), "usedSpace": MessageLookupByLibrary.simpleMessage("已用空间"), - "validTill": m64, + "validTill": m65, "verificationFailedPleaseTryAgain": MessageLookupByLibrary.simpleMessage("验证失败,请重试"), "verificationId": MessageLookupByLibrary.simpleMessage("验证 ID"), "verify": MessageLookupByLibrary.simpleMessage("验证"), "verifyEmail": MessageLookupByLibrary.simpleMessage("验证电子邮件"), - "verifyEmailID": m65, + "verifyEmailID": m66, "verifyIDLabel": MessageLookupByLibrary.simpleMessage("验证"), "verifyPasskey": MessageLookupByLibrary.simpleMessage("验证通行密钥"), "verifyPassword": MessageLookupByLibrary.simpleMessage("验证密码"), @@ -1277,6 +1283,9 @@ class MessageLookup extends MessageLookupByLibrary { "viewAddOnButton": MessageLookupByLibrary.simpleMessage("查看附加组件"), "viewAll": MessageLookupByLibrary.simpleMessage("查看全部"), "viewAllExifData": MessageLookupByLibrary.simpleMessage("查看所有 EXIF 数据"), + "viewLargeFiles": MessageLookupByLibrary.simpleMessage("大文件"), + "viewLargeFilesDesc": + MessageLookupByLibrary.simpleMessage("查看占用存储空间最多的文件"), "viewLogs": MessageLookupByLibrary.simpleMessage("查看日志"), "viewRecoveryKey": MessageLookupByLibrary.simpleMessage("查看恢复密钥"), "viewer": MessageLookupByLibrary.simpleMessage("查看者"), @@ -1288,11 +1297,11 @@ class MessageLookup extends MessageLookupByLibrary { "weAreOpenSource": MessageLookupByLibrary.simpleMessage("我们是开源的 !"), "weDontSupportEditingPhotosAndAlbumsThatYouDont": MessageLookupByLibrary.simpleMessage("我们不支持编辑您尚未拥有的照片和相册"), - "weHaveSendEmailTo": m66, + "weHaveSendEmailTo": m67, "weakStrength": MessageLookupByLibrary.simpleMessage("弱"), "welcomeBack": MessageLookupByLibrary.simpleMessage("欢迎回来!"), "yearly": MessageLookupByLibrary.simpleMessage("每年"), - "yearsAgo": m67, + "yearsAgo": m68, "yes": MessageLookupByLibrary.simpleMessage("是"), "yesCancel": MessageLookupByLibrary.simpleMessage("是的,取消"), "yesConvertToViewer": MessageLookupByLibrary.simpleMessage("是的,转换为查看者"), @@ -1318,7 +1327,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("莫开玩笑,您不能与自己分享"), "youDontHaveAnyArchivedItems": MessageLookupByLibrary.simpleMessage("您没有任何存档的项目。"), - "youHaveSuccessfullyFreedUp": m68, + "youHaveSuccessfullyFreedUp": m69, "yourAccountHasBeenDeleted": MessageLookupByLibrary.simpleMessage("您的账户已删除"), "yourMap": MessageLookupByLibrary.simpleMessage("您的地图"), diff --git a/mobile/lib/generated/l10n.dart b/mobile/lib/generated/l10n.dart index 00a00e4c2a..83708ad4ca 100644 --- a/mobile/lib/generated/l10n.dart +++ b/mobile/lib/generated/l10n.dart @@ -3162,6 +3162,16 @@ class S { ); } + /// `Check status` + String get checkStatus { + return Intl.message( + 'Check status', + name: 'checkStatus', + desc: '', + args: [], + ); + } + /// `Checking...` String get checking { return Intl.message( @@ -3432,6 +3442,16 @@ class S { ); } + /// `Save space on your device by clearing files that have been already backed up.` + String get freeUpDeviceSpaceDesc { + return Intl.message( + 'Save space on your device by clearing files that have been already backed up.', + name: 'freeUpDeviceSpaceDesc', + desc: '', + args: [], + ); + } + /// `✨ All clear` String get allClear { return Intl.message( @@ -3462,6 +3482,36 @@ class S { ); } + /// `Review and remove files that are exact duplicates.` + String get removeDuplicatesDesc { + return Intl.message( + 'Review and remove files that are exact duplicates.', + name: 'removeDuplicatesDesc', + desc: '', + args: [], + ); + } + + /// `Large files` + String get viewLargeFiles { + return Intl.message( + 'Large files', + name: 'viewLargeFiles', + desc: '', + args: [], + ); + } + + /// `View files that are consuming the most amount of storage` + String get viewLargeFilesDesc { + return Intl.message( + 'View files that are consuming the most amount of storage', + name: 'viewLargeFilesDesc', + desc: '', + args: [], + ); + } + /// `✨ No duplicates` String get noDuplicates { return Intl.message( @@ -6969,10 +7019,10 @@ class S { ); } - /// `Persons will be shown here once indexing is done` + /// `People will be shown here once indexing is done` String get searchFaceEmptySection { return Intl.message( - 'Persons will be shown here once indexing is done', + 'People will be shown here once indexing is done', name: 'searchFaceEmptySection', desc: '', args: [], @@ -7402,10 +7452,10 @@ class S { } /// `{freeAmount} {storageUnit} free` - String freeStorageSpace(Object freeAmount, Object storageUnit) { + String availableStorageSpace(Object freeAmount, Object storageUnit) { return Intl.message( '$freeAmount $storageUnit free', - name: 'freeStorageSpace', + name: 'availableStorageSpace', desc: '', args: [freeAmount, storageUnit], ); @@ -8368,6 +8418,36 @@ class S { ); } + /// `Verification is still pending` + String get passKeyPendingVerification { + return Intl.message( + 'Verification is still pending', + name: 'passKeyPendingVerification', + desc: '', + args: [], + ); + } + + /// `Session expired` + String get loginSessionExpired { + return Intl.message( + 'Session expired', + name: 'loginSessionExpired', + desc: '', + args: [], + ); + } + + /// `Your session has expired. Please login again.` + String get loginSessionExpiredDetails { + return Intl.message( + 'Your session has expired. Please login again.', + name: 'loginSessionExpiredDetails', + desc: '', + args: [], + ); + } + /// `Verify passkey` String get verifyPasskey { return Intl.message( @@ -8734,6 +8814,16 @@ class S { ); } + /// `Saving edits...` + String get savingEdits { + return Intl.message( + 'Saving edits...', + name: 'savingEdits', + desc: '', + args: [], + ); + } + /// `Auto pair` String get autoPair { return Intl.message( @@ -8793,6 +8883,66 @@ class S { args: [], ); } + + /// `Trim` + String get trim { + return Intl.message( + 'Trim', + name: 'trim', + desc: '', + args: [], + ); + } + + /// `Crop` + String get crop { + return Intl.message( + 'Crop', + name: 'crop', + desc: '', + args: [], + ); + } + + /// `Rotate` + String get rotate { + return Intl.message( + 'Rotate', + name: 'rotate', + desc: '', + args: [], + ); + } + + /// `Left` + String get left { + return Intl.message( + 'Left', + name: 'left', + desc: '', + args: [], + ); + } + + /// `Right` + String get right { + return Intl.message( + 'Right', + name: 'right', + desc: '', + args: [], + ); + } + + /// `What's new` + String get whatsNew { + return Intl.message( + 'What\'s new', + name: 'whatsNew', + desc: '', + args: [], + ); + } } class AppLocalizationDelegate extends LocalizationsDelegate { diff --git a/mobile/lib/l10n/intl_de.arb b/mobile/lib/l10n/intl_de.arb index 913af46b6c..ca19df932a 100644 --- a/mobile/lib/l10n/intl_de.arb +++ b/mobile/lib/l10n/intl_de.arb @@ -23,7 +23,7 @@ "sendEmail": "E-Mail senden", "deleteRequestSLAText": "Deine Anfrage wird innerhalb von 72 Stunden bearbeitet.", "deleteEmailRequest": "Bitte sende eine E-Mail an account-deletion@ente.io von Deiner bei uns hinterlegten E-Mail-Adresse.", - "entePhotosPerm": "ente benötigt die Erlaubnis, deine Fotos aufzubewahren", + "entePhotosPerm": "", "ok": "Ok", "createAccount": "Konto erstellen", "createNewAccount": "Neues Konto erstellen", @@ -48,7 +48,7 @@ "sorry": "Entschuldigung", "noRecoveryKeyNoDecryption": "Aufgrund unseres Ende-zu-Ende-Verschlüsselungsprotokolls können deine Daten nicht ohne dein Passwort oder deinen Wiederherstellungs-Schlüssel entschlüsselt werden", "verifyEmail": "E-Mail-Adresse verifizieren", - "toResetVerifyEmail": "Um Ihr Passwort zurückzusetzen, verifizieren Sie bitte zuerst Ihre E-Mail Adresse.", + "toResetVerifyEmail": "Um dein Passwort zurückzusetzen, verifiziere bitte zuerst deine E-Mail Adresse.", "checkInboxAndSpamFolder": "Bitte überprüfe deinen E-Mail-Posteingang (und Spam), um die Verifizierung abzuschließen", "tapToEnterCode": "Antippen, um den Code einzugeben", "resendEmail": "E-Mail erneut senden", @@ -143,7 +143,7 @@ "lostDevice": "Gerät verloren?", "verifyingRecoveryKey": "Wiederherstellungs-Schlüssel wird überprüft...", "recoveryKeyVerified": "Wiederherstellungs-Schlüssel überprüft", - "recoveryKeySuccessBody": "Sehr gut! Ihr Wiederherstellungsschlüssel ist gültig. Vielen Dank für die Verifizierung.\n\nBitte vergessen Sie nicht eine Kopie Ihres Wiederherstellungsschlüssels sicher aufzubewahren.", + "recoveryKeySuccessBody": "Sehr gut! Dein Wiederherstellungsschlüssel ist gültig. Vielen Dank für die Verifizierung.\n\nBitte vergiss nicht eine Kopie des Wiederherstellungsschlüssels sicher aufzubewahren.", "invalidRecoveryKey": "Der von Ihnen eingegebene Wiederherstellungsschlüssel ist nicht gültig. Bitte stellen Sie sicher das aus 24 Wörtern zusammen gesetzt ist und jedes dieser Worte richtig geschrieben wurde.\n\nSollten Sie den Wiederherstellungscode eingegeben haben, stellen Sie bitte sicher, dass dieser 64 Worte lang ist und ebenfall richtig geschrieben wurde.", "invalidKey": "Ungültiger Schlüssel", "tryAgain": "Erneut versuchen", @@ -225,7 +225,7 @@ }, "description": "Number of participants in an album, including the album owner." }, - "collabLinkSectionDescription": "Erstelle einen Link, um anderen zu ermöglichen, Fotos in deinem geteilten Album hinzuzufügen und zu sehen - ohne dass diese ein Konto von ente.io oder die App benötigen. Ideal, um Fotos von Events zu sammeln.", + "collabLinkSectionDescription": "Erstelle einen Link, mit dem andere Fotos in dem geteilten Album sehen und selbst welche hinzufügen können - ohne dass sie die ein Ente-Konto oder die App benötigen. Ideal um gemeinsam Fotos von Events zu sammeln.", "collectPhotos": "Fotos sammeln", "collaborativeLink": "Gemeinschaftlicher Link", "shareWithNonenteUsers": "Mit Nicht-Ente-Benutzern teilen", @@ -235,7 +235,7 @@ "linkHasExpired": "Link ist abgelaufen", "publicLinkEnabled": "Öffentlicher Link aktiviert", "shareALink": "Einen Link teilen", - "sharedAlbumSectionDescription": "Erstelle gemeinsame Alben mit anderen ente Benutzern, einschließlich solchen im kostenlosen Tarif.", + "sharedAlbumSectionDescription": "Erstelle gemeinsam mit anderen Ente-Nutzern geteilte Alben, inkl. Nutzern ohne Bezahltarif.", "shareWithPeopleSectionTitle": "{numberOfPeople, plural, =0 {Teile mit bestimmten Personen} =1 {Teilen mit 1 Person} other {Teilen mit {numberOfPeople} Personen}}", "@shareWithPeopleSectionTitle": { "placeholders": { @@ -259,12 +259,12 @@ }, "verificationId": "Verifizierungs-ID", "verifyEmailID": "Verifiziere {email}", - "emailNoEnteAccount": "{email} hat kein Ente-Konto.\n\nSenden Sie eine Einladung, um Fotos zu teilen.", + "emailNoEnteAccount": "{email} hat kein Ente-Konto.\n\nSende eine Einladung, um Fotos zu teilen.", "shareMyVerificationID": "Hier ist meine Verifizierungs-ID: {verificationID} für ente.io.", "shareTextConfirmOthersVerificationID": "Hey, kannst du bestätigen, dass dies deine ente.io Verifizierungs-ID ist: {verificationID}", "somethingWentWrong": "Irgendetwas ging schief", "sendInvite": "Einladung senden", - "shareTextRecommendUsingEnte": "Lade ente herunter, damit wir einfach Fotos und Videos in höchster Qualität teilen können\n\nhttps://ente.io", + "shareTextRecommendUsingEnte": "Hol dir Ente, damit wir ganz einfach Fotos und Videos in Originalqualität teilen können\n\nhttps://ente.io", "done": "Fertig", "applyCodeTitle": "Code nutzen", "enterCodeDescription": "Gib den Code deines Freundes ein, damit sie beide kostenlosen Speicherplatz erhalten", @@ -281,7 +281,7 @@ "claimMore": "Mehr einlösen!", "theyAlsoGetXGb": "Diese erhalten auch {storageAmountInGB} GB", "freeStorageOnReferralSuccess": "{storageAmountInGB} GB jedes Mal, wenn sich jemand mit deinem Code für einen bezahlten Tarif anmeldet", - "shareTextReferralCode": "ente Weiterempfehlungs-Code: {referralCode} \n\nEinlösen unter Einstellungen → Allgemein → Weiterempfehlungen, um {referralStorageInGB} GB kostenlos zu erhalten, sobald Sie einen kostenpflichtigen Tarif abgeschlossen haben\n\nhttps://ente.io", + "shareTextReferralCode": "Ente Weiterempfehlungs-Code: {referralCode} \n\nEinlösen unter Einstellungen → Allgemein → Weiterempfehlungen, um {referralStorageInGB} GB kostenlos zu erhalten, sobald Sie einen kostenpflichtigen Tarif abgeschlossen haben\n\nhttps://ente.io", "claimFreeStorage": "Freien Speicher einlösen", "inviteYourFriends": "Lade deine Freunde ein", "failedToFetchReferralDetails": "Die Weiterempfehlungs-Details können nicht abgerufen werden. Bitte versuche es später erneut.", @@ -304,6 +304,7 @@ } }, "faq": "Häufig gestellte Fragen", + "help": "Hilfe", "oopsSomethingWentWrong": "Ups. Leider ist ein Fehler aufgetreten", "peopleUsingYourCode": "Leute, die deinen Code verwenden", "eligible": "zulässig", @@ -333,7 +334,7 @@ "removeParticipantBody": "{userEmail} wird aus diesem geteilten Album entfernt\n\nAlle von ihnen hinzugefügte Fotos werden ebenfalls aus dem Album entfernt", "keepPhotos": "Fotos behalten", "deletePhotos": "Fotos löschen", - "inviteToEnte": "Zu ente einladen", + "inviteToEnte": "Zu Ente einladen", "removePublicLink": "Öffentlichen Link entfernen", "disableLinkMessage": "Der öffentliche Link zum Zugriff auf \"{albumName}\" wird entfernt.", "sharing": "Teilt...", @@ -349,10 +350,10 @@ "videoSmallCase": "Video", "photoSmallCase": "Foto", "singleFileDeleteHighlight": "Es wird aus allen Alben gelöscht.", - "singleFileInBothLocalAndRemote": "Dieses {fileType} existiert auf ente.io und deinem Gerät.", - "singleFileInRemoteOnly": "Dieses {fileType} wird auf ente.io gelöscht.", + "singleFileInBothLocalAndRemote": "Diese Datei ist sowohl in Ente als auch auf deinem Gerät.", + "singleFileInRemoteOnly": "Diese Datei wird von Ente gelöscht.", "singleFileDeleteFromDevice": "Dieses {fileType} wird von deinem Gerät gelöscht.", - "deleteFromEnte": "Auf ente.io löschen", + "deleteFromEnte": "Von Ente löschen", "yesDelete": "Ja, löschen", "movedToTrash": "In den Papierkorb verschoben", "deleteFromDevice": "Vom Gerät löschen", @@ -408,7 +409,7 @@ "manageDeviceStorage": "Gerätespeicher verwalten", "machineLearning": "Maschinelles Lernen", "magicSearch": "Magische Suche", - "magicSearchDescription": "Bitte beachten Sie, dass dies mehr Bandbreite nutzt und zu einem höheren Akkuverbrauch führt, bis alle Elemente indiziert sind.", + "mlIndexingDescription": "Bitte beachten Sie, dass Machine Learning zu einem höheren Bandbreiten- und Batterieverbrauch führt, bis alle Elemente indiziert sind.", "loadingModel": "Lade Modelle herunter...", "waitingForWifi": "Warte auf WLAN...", "status": "Status", @@ -444,7 +445,7 @@ "backupOverMobileData": "Über mobile Daten sichern", "backupVideos": "Videos sichern", "disableAutoLock": "Automatische Sperre deaktivieren", - "deviceLockExplanation": "Das Sperren des Gerätes verhindern, solange 'ente' im Vordergrund geöffnet ist und eine Sicherung läuft. \nDies wird für gewöhnlich nicht benötigt, kann aber dabei helfen große Transfers schneller durchzuführen.", + "deviceLockExplanation": "Verhindern, dass der Bildschirm gesperrt wird, während die App im Vordergrund ist und eine Sicherung läuft. Das ist normalerweise nicht notwendig, kann aber dabei helfen, große Uploads wie einen Erstimport schneller abzuschließen.", "about": "Allgemeine Informationen", "weAreOpenSource": "Unser Quellcode ist offen einsehbar!", "privacy": "Datenschutz", @@ -464,7 +465,7 @@ "authToInitiateAccountDeletion": "Bitte authentifizieren, um die Löschung des Kontos einzuleiten", "areYouSureYouWantToLogout": "Sind sie sicher, dass Sie sich abmelden wollen?", "yesLogout": "Ja, ausloggen", - "aNewVersionOfEnteIsAvailable": "Eine neuere Version von 'ente' ist verfügbar.", + "aNewVersionOfEnteIsAvailable": "Eine neue Version von Ente ist verfügbar.", "update": "Updaten", "installManually": "Manuell installieren", "criticalUpdateAvailable": "Kritisches Update ist verfügbar!", @@ -553,11 +554,11 @@ "systemTheme": "System", "freeTrial": "Kostenlose Testphase", "selectYourPlan": "Wähle dein Abo aus", - "enteSubscriptionPitch": "ente sichert deine Erinnerungsstücke, sodass sie immer für dich verfügbar sind, auch wenn du dein Gerät verlieren solltest.", + "enteSubscriptionPitch": "Ente sichert deine Erinnerungen, sodass sie dir nie verloren gehen, selbst wenn du dein Gerät verlierst.", "enteSubscriptionShareWithFamily": "Deine Familie kann zu deinem Abo hinzugefügt werden.", "currentUsageIs": "Aktuell genutzt werden ", "@currentUsageIs": { - "description": "This text is followed by storage usaged", + "description": "This text is followed by storage usage", "examples": { "0": "Current usage is 1.2 GB" }, @@ -619,7 +620,7 @@ "appleId": "Apple ID", "playstoreSubscription": "PlayStore Abo", "appstoreSubscription": "AppStore Abo", - "subAlreadyLinkedErrMessage": "Ihr {id} ist bereits mit einem anderen 'ente'-Konto verknüpft.\nWenn Sie Ihre {id} mit diesem Konto verwenden möchten, kontaktieren Sie bitte unseren Support'", + "subAlreadyLinkedErrMessage": "Ihr {id} ist bereits mit einem anderen Ente-Konto verknüpft.\nWenn Sie Ihre {id} mit diesem Konto verwenden möchten, kontaktieren Sie bitte unseren Support", "visitWebToManage": "Bitte rufen Sie \"web.ente.io\" auf um ihr Abo zu verwalten", "couldNotUpdateSubscription": "Abo konnte nicht aktualisiert werden", "pleaseContactSupportAndWeWillBeHappyToHelp": "Bitte kontaktieren Sie uns über support@ente.io wo wir Ihnen gerne weiterhelfen.", @@ -640,7 +641,7 @@ "thankYou": "Vielen Dank", "failedToVerifyPaymentStatus": "Überprüfung des Zahlungsstatus fehlgeschlagen", "pleaseWaitForSometimeBeforeRetrying": "Bitte warte kurz, bevor du es erneut versuchst", - "paymentFailedWithReason": "Leider ist deine Zahlung aus folgendem Grund fehlgeschlagen: {reason}", + "paymentFailedMessage": "Leider ist deine Zahlung fehlgeschlagen. Wende dich an unseren Support und wir helfen dir weiter!", "youAreOnAFamilyPlan": "Du bist im Familien-Tarif!", "contactFamilyAdmin": "Bitte kontaktiere {familyAdminEmail} um dein Abo zu verwalten", "leaveFamily": "Familienabo verlassen", @@ -664,9 +665,9 @@ "everywhere": "überall", "androidIosWebDesktop": "Android, iOS, Web, Desktop", "mobileWebDesktop": "Mobil, Web, Desktop", - "newToEnte": "Neu bei ente", + "newToEnte": "Neu bei Ente", "pleaseLoginAgain": "Bitte logge dich erneut ein", - "devAccountChanged": "Das Entwicklerkonto, das wir verwenden, um ente im App Store zu veröffentlichen, hat sich geändert. Aus diesem Grund musst du dich erneut anmelden.\n\nWir entschuldigen uns für die Unannehmlichkeiten, aber das war unvermeidlich.", + "autoLogoutMessage": "Aufgrund technischer Störungen wurden Sie abgemeldet. Wir entschuldigen uns für die Unannehmlichkeiten.", "yourSubscriptionHasExpired": "Dein Abonnement ist abgelaufen", "storageLimitExceeded": "Speichergrenze überschritten", "upgrade": "Upgrade", @@ -677,12 +678,12 @@ }, "backupFailed": "Sicherung fehlgeschlagen", "couldNotBackUpTryLater": "Deine Daten konnten nicht gesichert werden.\nWir versuchen es später erneut.", - "enteCanEncryptAndPreserveFilesOnlyIfYouGrant": "ente kann Dateien nur verschlüsselt sichern, wenn du uns darauf Zugriff gewährst", + "enteCanEncryptAndPreserveFilesOnlyIfYouGrant": "Ente kann Dateien nur verschlüsseln und sichern, wenn du den Zugriff darauf gewährst", "pleaseGrantPermissions": "Bitte erteile die nötigen Berechtigungen", "grantPermission": "Zugriff gewähren", "privateSharing": "Privates Teilen", "shareOnlyWithThePeopleYouWant": "Teile mit ausgewählten Personen", - "usePublicLinksForPeopleNotOnEnte": "Nutze öffentliche Links für Personen ohne ente.io Konto", + "usePublicLinksForPeopleNotOnEnte": "Verwenden Sie öffentliche Links für Personen, die kein Ente-Konto haben", "allowPeopleToAddPhotos": "Erlaube anderen das Hinzufügen von Fotos", "shareAnAlbumNow": "Teile jetzt ein Album", "collectEventPhotos": "Gemeinsam Event-Fotos sammeln", @@ -694,7 +695,7 @@ }, "onDevice": "Auf dem Gerät", "@onEnte": { - "description": "The text displayed above albums backed up to ente", + "description": "The text displayed above albums backed up to Ente", "type": "text" }, "onEnte": "Auf ente", @@ -740,7 +741,7 @@ "saveCollage": "Collage speichern", "collageSaved": "Collage in Galerie gespeichert", "collageLayout": "Layout", - "addToEnte": "Zu ente hinzufügen", + "addToEnte": "Zu Ente hinzufügen", "addToAlbum": "Zum Album hinzufügen", "delete": "Löschen", "hide": "Ausblenden", @@ -805,10 +806,10 @@ "photosAddedByYouWillBeRemovedFromTheAlbum": "Von dir hinzugefügte Fotos werden vom Album entfernt", "youveNoFilesInThisAlbumThatCanBeDeleted": "Du hast keine Dateien in diesem Album, die gelöscht werden können", "youDontHaveAnyArchivedItems": "Du hast keine archivierten Elemente.", - "ignoredFolderUploadReason": "Einige Dateien in diesem Album werden beim Upload ignoriert, weil sie zuvor auf ente gelöscht wurden.", + "ignoredFolderUploadReason": "Ein paar Dateien in diesem Album werden nicht hochgeladen, weil sie in der Vergangenheit schonmal aus Ente gelöscht wurden.", "resetIgnoredFiles": "Ignorierte Dateien zurücksetzen", - "deviceFilesAutoUploading": "Dateien, die zu diesem Album hinzugefügt werden, werden automatisch zu ente hochgeladen.", - "turnOnBackupForAutoUpload": "Aktiviere die Sicherung, um automatisch neu hinzugefügte Dateien dieses Ordners auf ente hochzuladen.", + "deviceFilesAutoUploading": "Dateien, die zu diesem Album hinzugefügt werden, werden automatisch zu Ente hochgeladen.", + "turnOnBackupForAutoUpload": "Aktiviere die Sicherung, um neue Dateien in diesem Ordner automatisch zu Ente hochzuladen.", "noHiddenPhotosOrVideos": "Keine versteckten Fotos oder Videos", "toHideAPhotoOrVideo": "Foto oder Video verstecken", "openTheItem": "• Element öffnen", @@ -834,6 +835,7 @@ "close": "Schließen", "setAs": "Festlegen als", "fileSavedToGallery": "Datei in Galerie gespeichert", + "filesSavedToGallery": "Dateien in Galerie gespeichert", "fileFailedToSaveToGallery": "Fehler beim Speichern der Datei in der Galerie", "download": "Herunterladen", "pressAndHoldToPlayVideo": "Gedrückt halten, um Video abzuspielen", @@ -885,7 +887,7 @@ "@freeUpSpaceSaving": { "description": "Text to tell user how much space they can free up by deleting items from the device" }, - "freeUpAccessPostDelete": "Sie können immer noch {count, plural, one {darauf} other {auf sie}} auf ente zugreifen, solange Sie ein aktives Abonnement haben", + "freeUpAccessPostDelete": "Du kannst immernoch über Ente {count, plural, one {darauf} other {auf sie}} zugreifen, solange du ein aktives Abo hast", "@freeUpAccessPostDelete": { "placeholders": { "count": { @@ -936,7 +938,7 @@ "renameFile": "Datei umbenennen", "enterFileName": "Dateinamen eingeben", "filesDeleted": "Dateien gelöscht", - "selectedFilesAreNotOnEnte": "Ausgewählte Dateien sind nicht auf ente", + "selectedFilesAreNotOnEnte": "Ausgewählte Dateien sind nicht auf Ente", "thisActionCannotBeUndone": "Diese Aktion kann nicht rückgängig gemacht werden", "emptyTrash": "Papierkorb leeren?", "permDeleteWarning": "Alle Elemente im Papierkorb werden dauerhaft gelöscht\n\nDiese Aktion kann nicht rückgängig gemacht werden", @@ -945,7 +947,7 @@ "permanentlyDeleteFromDevice": "Endgültig vom Gerät löschen?", "someOfTheFilesYouAreTryingToDeleteAre": "Einige der Dateien, die Sie löschen möchten, sind nur auf Ihrem Gerät verfügbar und können nicht wiederhergestellt werden, wenn sie gelöscht wurden", "theyWillBeDeletedFromAllAlbums": "Sie werden aus allen Alben gelöscht.", - "someItemsAreInBothEnteAndYourDevice": "Einige Elemente sind sowohl auf ente als auch auf Ihrem Gerät.", + "someItemsAreInBothEnteAndYourDevice": "Einige Elemente sind sowohl auf Ente als auch auf deinem Gerät.", "selectedItemsWillBeDeletedFromAllAlbumsAndMoved": "Ausgewählte Elemente werden aus allen Alben gelöscht und in den Papierkorb verschoben.", "theseItemsWillBeDeletedFromYourDevice": "Diese Elemente werden von deinem Gerät gelöscht.", "itLooksLikeSomethingWentWrongPleaseRetryAfterSome": "Etwas ist schiefgelaufen. Bitte versuche es später noch einmal. Sollte der Fehler weiter bestehen, kontaktiere unser Supportteam.", @@ -985,7 +987,7 @@ "fileTypesAndNames": "Dateitypen und -namen", "location": "Standort", "moments": "Momente", - "searchFaceEmptySection": "Finde alle Foto von einer Person", + "searchFaceEmptySection": "Personen werden hier angezeigt, sobald die Indizierung abgeschlossen ist", "searchDatesEmptySection": "Suche nach Datum, Monat oder Jahr", "searchLocationEmptySection": "Gruppiere Fotos, die innerhalb des Radius eines bestimmten Fotos aufgenommen wurden", "searchPeopleEmptySection": "Laden Sie Personen ein, damit Sie geteilte Fotos hier einsehen können", @@ -1040,7 +1042,7 @@ "@storageUsageInfo": { "description": "Example: 1.2 GB of 2 GB used or 100 GB or 2TB used" }, - "freeStorageSpace": "{freeAmount} {storageUnit} kostenlos", + "availableStorageSpace": "{freeAmount} {storageUnit} frei", "appVersion": "Version: {versionValue}", "verifyIDLabel": "Überprüfen", "fileInfoAddDescHint": "Beschreibung hinzufügen …", @@ -1051,7 +1053,7 @@ }, "setRadius": "Radius festlegen", "familyPlanPortalTitle": "Familie", - "familyPlanOverview": "Fügen Sie 5 Familienmitglieder zu Ihrem bestehenden Abo hinzu, ohne extra zu bezahlen.\n\nJedes Mitglied erhält einen eigenen privaten Raum und kann die Dateien von anderen nicht sehen, wenn sie nicht freigegeben werden.\n\nFamilien-Abos sind für Kunden verfügbar, die ein kostenpflichtiges ente Abonnement haben.\n\nMelden Sie sich jetzt an, um loszulegen!", + "familyPlanOverview": "Füge kostenlos 5 Familienmitglieder zu deinem bestehenden Abo hinzu.\n\nJedes Mitglied bekommt seinen eigenen privaten Bereich und kann die Dateien der anderen nur sehen, wenn sie geteilt werden.\n\nFamilien-Abos stehen Nutzern mit einem Bezahltarif zur Verfügung.\n\nMelde dich jetzt an, um loszulegen!", "androidBiometricHint": "Identität verifizieren", "@androidBiometricHint": { "description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters." @@ -1129,7 +1131,7 @@ "noAlbumsSharedByYouYet": "Noch keine Alben von dir geteilt", "sharedWithYou": "Mit dir geteilt", "sharedByYou": "Von dir geteilt", - "inviteYourFriendsToEnte": "Lade deine Freunde zu ente ein", + "inviteYourFriendsToEnte": "Lade deine Freunde zu Ente ein", "failedToDownloadVideo": "Herunterladen des Videos fehlgeschlagen", "hiding": "Verstecken...", "unhiding": "Einblenden...", @@ -1139,7 +1141,7 @@ "addToHiddenAlbum": "Zum versteckten Album hinzufügen", "moveToHiddenAlbum": "Zu verstecktem Album verschieben", "fileTypes": "Dateitypen", - "deleteConfirmDialogBody": "Dieses Konto ist mit anderen ente Apps verknüpft, sofern du diese benutzt.\\n\\nDeine hochgeladenen Daten werden zur permanenten Löschung freigegeben. Dies gilt für alle ente Apps.", + "deleteConfirmDialogBody": "Dieses Konto ist mit anderen Ente-Apps verknüpft, falls du welche verwendest. Deine hochgeladenen Daten werden in allen Ente-Apps zur Löschung vorgemerkt und dein Konto wird endgültig gelöscht.", "hearUsWhereTitle": "Wie hast du von Ente erfahren? (optional)", "hearUsExplanation": "Wir tracken keine App-Installationen. Es würde uns jedoch helfen, wenn du uns mitteilst, wie du von uns erfahren hast!", "viewAddOnButton": "Zeige Add-ons", @@ -1169,6 +1171,7 @@ } }, "faces": "Gesichter", + "people": "Personen", "contents": "Inhalte", "addNew": "Hinzufügen", "@addNew": { @@ -1185,10 +1188,8 @@ "selectALocation": "Standort auswählen", "selectALocationFirst": "Wähle zuerst einen Standort", "changeLocationOfSelectedItems": "Standort der gewählten Elemente ändern?", - "editsToLocationWillOnlyBeSeenWithinEnte": "Änderungen des Standorts werden nur in ente sichtbar sein", + "editsToLocationWillOnlyBeSeenWithinEnte": "Edits to location will only be seen within Ente", "cleanUncategorized": "Unkategorisiert leeren", - "addAName": "Add a name", - "findPeopleByName": "Find people quickly by searching by name", "cleanUncategorizedDescription": "Entferne alle Dateien von \"Unkategorisiert\" die in anderen Alben vorhanden sind", "waitingForVerification": "Warte auf Bestätigung...", "passkey": "Passkey", @@ -1202,16 +1203,37 @@ "joinDiscord": "Discord beitreten", "locations": "Orte", "descriptions": "Beschreibungen", - "addViewers": "{count, plural, zero {Add viewer} one {Add viewer} other {Add viewers}}", - "addCollaborators": "{count, plural, zero {Add collaborator} one {Add collaborator} other {Add collaborators}}", - "longPressAnEmailToVerifyEndToEndEncryption": "Long press an email to verify end to end encryption.", - "createCollaborativeLink": "Create collaborative link", - "search": "Search", - "enterPersonName": "Enter person name", - "removePersonLabel": "Remove person label", - "faceRecognition": "Face recognition", - "faceRecognitionIndexingDescription": "Please note that this will result in a higher bandwidth and battery usage until all items are indexed.", - "foundFaces": "Found faces", - "clusteringProgress": "Clustering progress", - "indexingIsPaused": "Indexing is paused, will automatically resume when device is ready" + "addAName": "Füge einen Namen hinzu", + "findPeopleByName": "Finde Personen schnell nach Namen", + "addViewers": "{count, plural, one {Betrachter} other {Betrachter}} hinzufügen", + "addCollaborators": "{count, plural, one {Teilnehmer} other {Teilnehmer}} hinzufügen", + "longPressAnEmailToVerifyEndToEndEncryption": "Lange auf eine E-Mail drücken, um die Ende-zu-Ende-Verschlüsselung zu überprüfen.", + "developerSettingsWarning": "Bist du sicher, dass du Entwicklereinstellungen bearbeiten willst?", + "developerSettings": "Entwicklereinstellungen", + "serverEndpoint": "Server Endpunkt", + "invalidEndpoint": "Ungültiger Endpunkt", + "invalidEndpointMessage": "Der eingegebene Endpunkt ist ungültig. Gib einen gültigen Endpunkt ein und versuch es nochmal.", + "endpointUpdatedMessage": "Endpunkt erfolgreich geändert", + "customEndpoint": "Verbunden mit {endpoint}", + "createCollaborativeLink": "Gemeinschaftlichen Link erstellen", + "search": "Suche", + "enterPersonName": "Namen der Person eingeben", + "removePersonLabel": "Personenetikett entfernen", + "autoPairDesc": "Automatisches Verbinden funktioniert nur mit Geräten, die Chromecast unterstützen.", + "manualPairDesc": "\"Mit PIN verbinden\" funktioniert mit jedem Bildschirm, auf dem du dein Album sehen möchtest.", + "connectToDevice": "Mit Gerät verbinden", + "autoCastDialogBody": "Verfügbare Cast-Geräte werden hier angezeigt.", + "autoCastiOSPermission": "Stelle sicher, dass die Ente-App auf das lokale Netzwerk zugreifen darf. Das kannst du in den Einstellungen unter \"Datenschutz\".", + "noDeviceFound": "Kein Gerät gefunden", + "stopCastingTitle": "Übertragung beenden", + "stopCastingBody": "Möchtest du die Übertragung beenden?", + "castIPMismatchTitle": "Album konnte nicht auf den Bildschirm übertragen werden", + "castIPMismatchBody": "Stelle sicher, dass du im selben Netzwerk bist wie der Fernseher.", + "pairingComplete": "Verbunden", + "autoPair": "Automatisch verbinden", + "pairWithPin": "Mit PIN verbinden", + "faceRecognition": "Gesichtserkennung", + "foundFaces": "Gesichter gefunden", + "clusteringProgress": "Fortschritt beim Clustering", + "indexingIsPaused": "Die Indizierung ist unterbrochen. Sie wird automatisch fortgesetzt, wenn das Gerät bereit ist." } \ No newline at end of file diff --git a/mobile/lib/l10n/intl_en.arb b/mobile/lib/l10n/intl_en.arb index f1eb35b18a..4ca8ab4353 100644 --- a/mobile/lib/l10n/intl_en.arb +++ b/mobile/lib/l10n/intl_en.arb @@ -451,6 +451,7 @@ "privacy": "Privacy", "terms": "Terms", "checkForUpdates": "Check for updates", + "checkStatus": "Check status", "checking": "Checking...", "youAreOnTheLatestVersion": "You are on the latest version", "account": "Account", @@ -478,9 +479,13 @@ "backedUpFolders": "Backed up folders", "backup": "Backup", "freeUpDeviceSpace": "Free up device space", + "freeUpDeviceSpaceDesc": "Save space on your device by clearing files that have been already backed up.", "allClear": "✨ All clear", "noDeviceThatCanBeDeleted": "You've no files on this device that can be deleted", "removeDuplicates": "Remove duplicates", + "removeDuplicatesDesc": "Review and remove files that are exact duplicates.", + "viewLargeFiles": "Large files", + "viewLargeFilesDesc": "View files that are consuming the most amount of storage", "noDuplicates": "✨ No duplicates", "youveNoDuplicateFilesThatCanBeCleared": "You've no duplicate files that can be cleared", "success": "Success", @@ -987,7 +992,7 @@ "fileTypesAndNames": "File types and names", "location": "Location", "moments": "Moments", - "searchFaceEmptySection": "Persons will be shown here once indexing is done", + "searchFaceEmptySection": "People will be shown here once indexing is done", "searchDatesEmptySection": "Search by a date, month or year", "searchLocationEmptySection": "Group photos that are taken within some radius of a photo", "searchPeopleEmptySection": "Invite people, and you'll see all photos shared by them here", @@ -1042,7 +1047,7 @@ "@storageUsageInfo": { "description": "Example: 1.2 GB of 2 GB used or 100 GB or 2TB used" }, - "freeStorageSpace": "{freeAmount} {storageUnit} free", + "availableStorageSpace": "{freeAmount} {storageUnit} free", "appVersion": "Version: {versionValue}", "verifyIDLabel": "Verify", "fileInfoAddDescHint": "Add a description...", @@ -1194,6 +1199,9 @@ "waitingForVerification": "Waiting for verification...", "passkey": "Passkey", "passkeyAuthTitle": "Passkey verification", + "passKeyPendingVerification": "Verification is still pending", + "loginSessionExpired" : "Session expired", + "loginSessionExpiredDetails": "Your session has expired. Please login again.", "verifyPasskey": "Verify passkey", "playOnTv": "Play album on TV", "pair": "Pair", @@ -1230,10 +1238,18 @@ "castIPMismatchTitle": "Failed to cast album", "castIPMismatchBody": "Please make sure you are on the same network as the TV.", "pairingComplete": "Pairing complete", + "savingEdits": "Saving edits...", "autoPair": "Auto pair", "pairWithPin": "Pair with PIN", "faceRecognition": "Face recognition", "foundFaces": "Found faces", "clusteringProgress": "Clustering progress", - "indexingIsPaused": "Indexing is paused. It will automatically resume when device is ready." + "indexingIsPaused": "Indexing is paused. It will automatically resume when device is ready.", + "trim": "Trim", + "crop": "Crop", + "rotate": "Rotate", + "left": "Left", + "right": "Right", + "whatsNew": "What's new" } + diff --git a/mobile/lib/l10n/intl_es.arb b/mobile/lib/l10n/intl_es.arb index 22acb2b333..a9ee0b4d9c 100644 --- a/mobile/lib/l10n/intl_es.arb +++ b/mobile/lib/l10n/intl_es.arb @@ -949,7 +949,6 @@ "@storageUsageInfo": { "description": "Example: 1.2 GB of 2 GB used or 100 GB or 2TB used" }, - "freeStorageSpace": "{freeAmount} {storageUnit} gratis", "appVersion": "Versión: {versionValue}", "verifyIDLabel": "Verificar", "fileInfoAddDescHint": "Añadir una descripción...", diff --git a/mobile/lib/l10n/intl_fr.arb b/mobile/lib/l10n/intl_fr.arb index 90c0ad80e6..44206d6347 100644 --- a/mobile/lib/l10n/intl_fr.arb +++ b/mobile/lib/l10n/intl_fr.arb @@ -1014,7 +1014,7 @@ "@storageUsageInfo": { "description": "Example: 1.2 GB of 2 GB used or 100 GB or 2TB used" }, - "freeStorageSpace": "{freeAmount} {storageUnit} libre", + "availableStorageSpace": "{freeAmount} {storageUnit} libre", "appVersion": "Version : {versionValue}", "verifyIDLabel": "Vérifier", "fileInfoAddDescHint": "Ajouter une description...", diff --git a/mobile/lib/l10n/intl_it.arb b/mobile/lib/l10n/intl_it.arb index 071933ae5b..ddcaa3aac0 100644 --- a/mobile/lib/l10n/intl_it.arb +++ b/mobile/lib/l10n/intl_it.arb @@ -1004,7 +1004,7 @@ "@storageUsageInfo": { "description": "Example: 1.2 GB of 2 GB used or 100 GB or 2TB used" }, - "freeStorageSpace": "{freeAmount} {storageUnit} liberi", + "availableStorageSpace": "{freeAmount} {storageUnit} liberi", "appVersion": "Versione: {versionValue}", "verifyIDLabel": "Verifica", "fileInfoAddDescHint": "Aggiungi descrizione...", diff --git a/mobile/lib/l10n/intl_nl.arb b/mobile/lib/l10n/intl_nl.arb index f54f6b6043..968345316d 100644 --- a/mobile/lib/l10n/intl_nl.arb +++ b/mobile/lib/l10n/intl_nl.arb @@ -1042,7 +1042,7 @@ "@storageUsageInfo": { "description": "Example: 1.2 GB of 2 GB used or 100 GB or 2TB used" }, - "freeStorageSpace": "{freeAmount} {storageUnit} vrij", + "availableStorageSpace": "{freeAmount} {storageUnit} vrij", "appVersion": "Versie: {versionValue}", "verifyIDLabel": "Verifiëren", "fileInfoAddDescHint": "Voeg een beschrijving toe...", diff --git a/mobile/lib/l10n/intl_pt.arb b/mobile/lib/l10n/intl_pt.arb index 0d1d3b799d..28628e5905 100644 --- a/mobile/lib/l10n/intl_pt.arb +++ b/mobile/lib/l10n/intl_pt.arb @@ -478,9 +478,13 @@ "backedUpFolders": "Backup de pastas concluído", "backup": "Backup", "freeUpDeviceSpace": "Liberar espaço no dispositivo", + "freeUpDeviceSpaceDesc": "Economize espaço no seu dispositivo limpando arquivos que já foram salvos em backup.", "allClear": "✨ Tudo limpo", "noDeviceThatCanBeDeleted": "Você não tem nenhum arquivo neste dispositivo que pode ser excluído", "removeDuplicates": "Excluir duplicados", + "removeDuplicatesDesc": "Revise e remova arquivos que sejam duplicatas exatas.", + "viewLargeFiles": "Arquivos grandes", + "viewLargeFilesDesc": "Ver arquivos que estão consumindo mais espaço de armazenamento", "noDuplicates": "✨ Sem duplicados", "youveNoDuplicateFilesThatCanBeCleared": "Você não tem arquivos duplicados que possam ser limpos", "success": "Bem-sucedido", @@ -667,7 +671,7 @@ "mobileWebDesktop": "Mobile, Web, Desktop", "newToEnte": "Novo no Ente", "pleaseLoginAgain": "Por favor, faça login novamente", - "devAccountChanged": "A conta de desenvolvedor que usamos para publicar o Ente na App Store foi alterada. Por esse motivo, você precisará fazer entrar novamente.\n\nPedimos desculpas pelo inconveniente, mas isso era inevitável.", + "autoLogoutMessage": "Devido a erros técnicos, você foi desconectado. Pedimos desculpas pelo inconveniente.", "yourSubscriptionHasExpired": "A sua assinatura expirou", "storageLimitExceeded": "Limite de armazenamento excedido", "upgrade": "Aprimorar", @@ -981,7 +985,7 @@ "loadMessage5": "Nosso código-fonte e criptografia foram auditadas externamente", "loadMessage6": "Você pode compartilhar links para seus álbuns com seus entes queridos", "loadMessage7": "Nossos aplicativos móveis são executados em segundo plano para criptografar e fazer backup de quaisquer novas fotos que você clique", - "loadMessage8": "web.ente.io tem um upload rápido", + "loadMessage8": "web.ente.io tem um envio mais rápido", "loadMessage9": "Nós usamos Xchacha20Poly1305 para criptografar seus dados com segurança", "photoDescriptions": "Descrições das fotos", "fileTypesAndNames": "Tipos de arquivo e nomes", @@ -1042,7 +1046,7 @@ "@storageUsageInfo": { "description": "Example: 1.2 GB of 2 GB used or 100 GB or 2TB used" }, - "freeStorageSpace": "{freeAmount} {storageUnit} livre", + "availableStorageSpace": "{freeAmount} {storageUnit} livre", "appVersion": "Versão: {versionValue}", "verifyIDLabel": "Verificar", "fileInfoAddDescHint": "Adicionar descrição...", diff --git a/mobile/lib/l10n/intl_zh.arb b/mobile/lib/l10n/intl_zh.arb index 2b0149685f..0a4b7a8b85 100644 --- a/mobile/lib/l10n/intl_zh.arb +++ b/mobile/lib/l10n/intl_zh.arb @@ -478,9 +478,13 @@ "backedUpFolders": "已备份的文件夹", "backup": "备份", "freeUpDeviceSpace": "释放设备空间", + "freeUpDeviceSpaceDesc": "通过清除已备份的文件来节省设备空间。", "allClear": "✨ 全部清除", "noDeviceThatCanBeDeleted": "您在此设备上没有可被删除的文件", "removeDuplicates": "移除重复内容", + "removeDuplicatesDesc": "检查并删除完全重复的文件。", + "viewLargeFiles": "大文件", + "viewLargeFilesDesc": "查看占用存储空间最多的文件", "noDuplicates": "✨ 没有重复内容", "youveNoDuplicateFilesThatCanBeCleared": "您没有可以被清除的重复文件", "success": "成功", @@ -667,7 +671,7 @@ "mobileWebDesktop": "移动端, 网页端, 桌面端", "newToEnte": "初来 Ente", "pleaseLoginAgain": "请重新登录", - "devAccountChanged": "我们用于在 App Store 上发布 Ente 的开发者账户已更改。因此,您需要重新登录。\n\n对于给您带来的不便,我们深表歉意,但这是不可避免的。", + "autoLogoutMessage": "由于技术故障,您已退出登录。对于由此造成的不便,我们深表歉意。", "yourSubscriptionHasExpired": "您的订阅已过期", "storageLimitExceeded": "已超出存储限制", "upgrade": "升级", @@ -1042,7 +1046,7 @@ "@storageUsageInfo": { "description": "Example: 1.2 GB of 2 GB used or 100 GB or 2TB used" }, - "freeStorageSpace": "{freeAmount} {storageUnit} 空闲", + "availableStorageSpace": "{freeAmount} {storageUnit} 空闲", "appVersion": "版本: {versionValue}", "verifyIDLabel": "验证", "fileInfoAddDescHint": "添加说明...", diff --git a/mobile/lib/l10n/l10n.dart b/mobile/lib/l10n/l10n.dart index d4e31a2ece..9f0f65e18c 100644 --- a/mobile/lib/l10n/l10n.dart +++ b/mobile/lib/l10n/l10n.dart @@ -16,6 +16,9 @@ const List appSupportedLocales = [ Locale('fr'), Locale('it'), Locale("nl"), + Locale("pt", "BR"), + Locale("ru"), + Locale("tr"), Locale("zh", "CN"), ]; diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 4f7433c862..4d0d11fae8 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:io'; -import "dart:isolate"; import "package:adaptive_theme/adaptive_theme.dart"; import 'package:background_fetch/background_fetch.dart'; @@ -128,7 +127,6 @@ Future _runBackgroundTask(String taskId, {String mode = 'normal'}) async { await _sync('bgTaskActiveProcess'); BackgroundFetch.finish(taskId); } else { - // ignore: unawaited_futures _runWithLogs( () async { _logger.info("Starting background task in $mode mode"); @@ -136,7 +134,7 @@ Future _runBackgroundTask(String taskId, {String mode = 'normal'}) async { _runInBackground(taskId); }, prefix: "[bg]", - ); + ).ignore(); } } @@ -183,18 +181,21 @@ Future _init(bool isBackground, {String via = ''}) async { bool initComplete = false; Future.delayed(const Duration(seconds: 15), () { if (!initComplete && !isBackground) { + _logger.severe("Stuck on splash screen for >= 15 seconds"); sendLogsForInit( "support@ente.io", - "Stuck on splash screen for >= 15 seconds", + "Stuck on splash screen for >= 15 seconds on ${Platform.operatingSystem}", null, ); } }); + if (!isBackground) _heartBeatOnInit(0); _isProcessRunning = true; _logger.info("Initializing... inBG =$isBackground via: $via"); final SharedPreferences preferences = await SharedPreferences.getInstance(); await _logFGHeartBeatInfo(); + _logger.info("_logFGHeartBeatInfo done"); unawaited(_scheduleHeartBeat(preferences, isBackground)); AppLifecycleService.instance.init(preferences); if (isBackground) { @@ -205,29 +206,69 @@ Future _init(bool isBackground, {String via = ''}) async { // Start workers asynchronously. No need to wait for them to start Computer.shared().turnOn(workersCount: 4).ignore(); CryptoUtil.init(); - await Configuration.instance.init(); - await NetworkClient.instance.init(); - ServiceLocator.instance.init(preferences, NetworkClient.instance.enteDio); - await UserService.instance.init(); - await EntityService.instance.init(); - LocationService.instance.init(preferences); + _logger.info("Configuration init"); + await Configuration.instance.init(); + _logger.info("Configuration done"); + + _logger.info("NetworkClient init"); + await NetworkClient.instance.init(); + _logger.info("NetworkClient init done"); + + ServiceLocator.instance.init(preferences, NetworkClient.instance.enteDio); + + _logger.info("UserService init"); + await UserService.instance.init(); + _logger.info("UserService init done"); + + _logger.info("EntityService init"); + await EntityService.instance.init(); + _logger.info("EntityService init done"); + + _logger.info("LocationService init"); + LocationService.instance.init(preferences); + _logger.info("LocationService init done"); + + _logger.info("UserRemoteFlagService init"); await UserRemoteFlagService.instance.init(); + _logger.info("UserRemoteFlagService init done"); + + _logger.info("UpdateService init"); await UpdateService.instance.init(); + _logger.info("UpdateService init done"); + + _logger.info("BillingService init"); BillingService.instance.init(); + _logger.info("BillingService init done"); + + _logger.info("CollectionsService init"); await CollectionsService.instance.init(preferences); + _logger.info("CollectionsService init done"); + FavoritesService.instance.initFav().ignore(); + + _logger.info("FileUploader init"); await FileUploader.instance.init(preferences, isBackground); + _logger.info("FileUploader init done"); + + _logger.info("LocalSyncService init"); await LocalSyncService.instance.init(preferences); + _logger.info("LocalSyncService init done"); + TrashSyncService.instance.init(preferences); RemoteSyncService.instance.init(preferences); + + _logger.info("SyncService init"); await SyncService.instance.init(preferences); + _logger.info("SyncService init done"); + MemoriesService.instance.init(preferences); LocalSettings.instance.init(preferences); LocalFileUpdateService.instance.init(preferences); SearchService.instance.init(); StorageBonusService.instance.init(preferences); RemoteFileMLService.instance.init(preferences); + _logger.info("RemoteFileMLService done"); if (!isBackground && Platform.isAndroid && await HomeWidgetService.instance.countHomeWidgets() == 0) { @@ -242,9 +283,11 @@ Future _init(bool isBackground, {String via = ''}) async { // ); // }); } - + _logger.info("PushService/HomeWidget done"); unawaited(SemanticSearchService.instance.init()); MachineLearningController.instance.init(); + + _logger.info("MachineLearningController done"); if (flagService.faceSearchEnabled) { unawaited(FaceMlService.instance.init()); } else { @@ -266,6 +309,15 @@ Future _init(bool isBackground, {String via = ''}) async { } } +void _heartBeatOnInit(int i) { + if (i <= 15) { + Future.delayed(const Duration(seconds: 1), () { + _logger.info("init Heartbeat $i"); + _heartBeatOnInit(i + 1); + }); + } +} + Future _sync(String caller) async { if (!AppLifecycleService.instance.isForeground) { _logger.info("Syncing in background caller $caller"); @@ -357,15 +409,10 @@ Future _killBGTask([String? taskId]) async { DateTime.now().microsecondsSinceEpoch, ); final prefs = await SharedPreferences.getInstance(); - await prefs.remove(kLastBGTaskHeartBeatTime); if (taskId != null) { BackgroundFetch.finish(taskId); } - - ///Band aid for background process not getting killed. Should migrate to using - ///workmanager instead of background_fetch. - Isolate.current.kill(); } Future _logFGHeartBeatInfo() async { diff --git a/mobile/lib/models/search/search_types.dart b/mobile/lib/models/search/search_types.dart index a13fd57dcb..e6dab467e1 100644 --- a/mobile/lib/models/search/search_types.dart +++ b/mobile/lib/models/search/search_types.dart @@ -10,10 +10,10 @@ import "package:photos/events/people_changed_event.dart"; import "package:photos/generated/l10n.dart"; import "package:photos/models/collection/collection.dart"; import "package:photos/models/collection/collection_items.dart"; -import "package:photos/models/search/generic_search_result.dart"; import "package:photos/models/search/search_result.dart"; import "package:photos/models/typedefs.dart"; import "package:photos/services/collections_service.dart"; +import "package:photos/services/machine_learning/semantic_search/frameworks/ml_framework.dart"; import "package:photos/services/search_service.dart"; import "package:photos/ui/viewer/gallery/collection_page.dart"; import "package:photos/ui/viewer/location/add_location_sheet.dart"; @@ -41,8 +41,7 @@ enum ResultType { enum SectionType { face, location, - // Grouping based on ML or manual tagging - content, + magic, // includes year, month , day, event ResultType moment, album, @@ -58,8 +57,8 @@ extension SectionTypeExtensions on SectionType { switch (this) { case SectionType.face: return S.of(context).people; - case SectionType.content: - return S.of(context).contents; + case SectionType.magic: + return "Magic"; case SectionType.moment: return S.of(context).moments; case SectionType.location: @@ -79,8 +78,8 @@ extension SectionTypeExtensions on SectionType { switch (this) { case SectionType.face: return S.of(context).searchFaceEmptySection; - case SectionType.content: - return "Contents"; + case SectionType.magic: + return "Magic"; case SectionType.moment: return S.of(context).searchDatesEmptySection; case SectionType.location: @@ -102,7 +101,7 @@ extension SectionTypeExtensions on SectionType { switch (this) { case SectionType.face: return false; - case SectionType.content: + case SectionType.magic: return false; case SectionType.moment: return false; @@ -125,7 +124,7 @@ extension SectionTypeExtensions on SectionType { switch (this) { case SectionType.face: return false; - case SectionType.content: + case SectionType.magic: return false; case SectionType.moment: return false; @@ -147,9 +146,9 @@ extension SectionTypeExtensions on SectionType { case SectionType.face: // todo: later return "Setup"; - case SectionType.content: + case SectionType.magic: // todo: later - return "Add tags"; + return "temp"; case SectionType.moment: return S.of(context).addNew; case SectionType.location: @@ -169,7 +168,7 @@ extension SectionTypeExtensions on SectionType { switch (this) { case SectionType.face: return Icons.adaptive.arrow_forward_outlined; - case SectionType.content: + case SectionType.magic: return null; case SectionType.moment: return null; @@ -250,8 +249,8 @@ extension SectionTypeExtensions on SectionType { switch (this) { case SectionType.face: return SearchService.instance.getAllFace(limit); - case SectionType.content: - return Future.value(List.empty()); + case SectionType.magic: + return SearchService.instance.getMagicSectionResutls(); case SectionType.moment: return SearchService.instance.getRandomMomentsSearchResults(context); @@ -293,6 +292,8 @@ extension SectionTypeExtensions on SectionType { switch (this) { case SectionType.location: return [Bus.instance.on()]; + case SectionType.magic: + return [Bus.instance.on()]; default: return []; } diff --git a/mobile/lib/services/collections_service.dart b/mobile/lib/services/collections_service.dart index 5b16bc70fb..3631d00535 100644 --- a/mobile/lib/services/collections_service.dart +++ b/mobile/lib/services/collections_service.dart @@ -431,7 +431,8 @@ class CollectionsService { for (final collection in collections) { if (collection.type == CollectionType.uncategorized || collection.isQuickLinkCollection() || - collection.isHidden()) { + collection.isHidden() || + collection.isArchived()) { continue; } if (collection.type == CollectionType.favorites) { diff --git a/mobile/lib/services/machine_learning/face_ml/face_filtering/face_filtering_constants.dart b/mobile/lib/services/machine_learning/face_ml/face_filtering/face_filtering_constants.dart index b0f954f8f9..64ca82dd97 100644 --- a/mobile/lib/services/machine_learning/face_ml/face_filtering/face_filtering_constants.dart +++ b/mobile/lib/services/machine_learning/face_ml/face_filtering/face_filtering_constants.dart @@ -17,4 +17,4 @@ const kHighQualityFaceScore = 0.90; const kMinFaceDetectionScore = FaceDetectionService.kMinScoreSigmoidThreshold; /// The minimum cluster size for displaying a cluster in the UI -const kMinimumClusterSizeSearchResult = 20; +const kMinimumClusterSizeSearchResult = 10; diff --git a/mobile/lib/services/machine_learning/face_ml/face_ml_service.dart b/mobile/lib/services/machine_learning/face_ml/face_ml_service.dart index 87a707995c..e4620f6676 100644 --- a/mobile/lib/services/machine_learning/face_ml/face_ml_service.dart +++ b/mobile/lib/services/machine_learning/face_ml/face_ml_service.dart @@ -101,9 +101,11 @@ class FaceMlService { bool _shouldSyncPeople = false; bool _isSyncing = false; - final int _fileDownloadLimit = 5; + final int _fileDownloadLimit = 10; final int _embeddingFetchLimit = 200; final int _kForceClusteringFaceCount = 8000; + final int _kcooldownLimit = 300; + static const Duration _kCooldownDuration = Duration(minutes: 3); Future init({bool initializeImageMlIsolate = false}) async { if (LocalSettings.instance.isFaceIndexingEnabled == false) { @@ -167,7 +169,8 @@ class FaceMlService { pauseIndexingAndClustering(); } }); - if (Platform.isIOS && MachineLearningController.instance.isDeviceHealthy) { + if (Platform.isIOS && + MachineLearningController.instance.isDeviceHealthy) { _logger.info("Starting face indexing and clustering on iOS from init"); unawaited(indexAndClusterAll()); } @@ -272,7 +275,8 @@ class FaceMlService { switch (function) { case FaceMlOperation.analyzeImage: final time = DateTime.now(); - final FaceMlResult result = await FaceMlService.analyzeImageSync(args); + final FaceMlResult result = + await FaceMlService.analyzeImageSync(args); dev.log( "`analyzeImageSync` function executed in ${DateTime.now().difference(time).inMilliseconds} ms", ); @@ -285,7 +289,8 @@ class FaceMlService { error: e, stackTrace: stackTrace, ); - sendPort.send({'error': e.toString(), 'stackTrace': stackTrace.toString()}); + sendPort + .send({'error': e.toString(), 'stackTrace': stackTrace.toString()}); } }); } @@ -298,6 +303,10 @@ class FaceMlService { return _functionLock.synchronized(() async { _resetInactivityTimer(); + if (_shouldPauseIndexingAndClustering) { + return null; + } + final completer = Completer(); final answerPort = ReceivePort(); @@ -369,7 +378,8 @@ class FaceMlService { await sync(forceSync: _shouldSyncPeople); - final int unclusteredFacesCount = await FaceMLDataDB.instance.getUnclusteredFaceCount(); + final int unclusteredFacesCount = + await FaceMLDataDB.instance.getUnclusteredFaceCount(); if (unclusteredFacesCount > _kForceClusteringFaceCount) { _logger.info( "There are $unclusteredFacesCount unclustered faces, doing clustering first", @@ -396,10 +406,13 @@ class FaceMlService { _isIndexingOrClusteringRunning = true; _logger.info('starting image indexing'); - final w = (kDebugMode ? EnteWatch('prepare indexing files') : null)?..start(); - final Map alreadyIndexedFiles = await FaceMLDataDB.instance.getIndexedFileIds(); + final w = (kDebugMode ? EnteWatch('prepare indexing files') : null) + ?..start(); + final Map alreadyIndexedFiles = + await FaceMLDataDB.instance.getIndexedFileIds(); w?.log('getIndexedFileIds'); - final List enteFiles = await SearchService.instance.getAllFiles(); + final List enteFiles = + await SearchService.instance.getAllFiles(); w?.log('getAllFiles'); // Make sure the image conversion isolate is spawned @@ -408,6 +421,7 @@ class FaceMlService { int fileAnalyzedCount = 0; int fileSkippedCount = 0; + int cooldownCount = 0; final stopwatch = Stopwatch()..start(); final List filesWithLocalID = []; final List filesWithoutLocalID = []; @@ -426,7 +440,8 @@ class FaceMlService { } } w?.log('sifting through all normal files'); - final List hiddenFiles = await SearchService.instance.getHiddenFiles(); + final List hiddenFiles = + await SearchService.instance.getHiddenFiles(); w?.log('getHiddenFiles: ${hiddenFiles.length} hidden files'); for (final EnteFile enteFile in hiddenFiles) { if (_skipAnalysisEnteFile(enteFile, alreadyIndexedFiles)) { @@ -442,21 +457,22 @@ class FaceMlService { sortedBylocalID.addAll(filesWithoutLocalID); sortedBylocalID.addAll(hiddenFilesToIndex); w?.log('preparing all files to index'); - final List> chunks = sortedBylocalID.chunks(_embeddingFetchLimit); + final List> chunks = + sortedBylocalID.chunks(_embeddingFetchLimit); int fetchedCount = 0; outerLoop: for (final chunk in chunks) { - final futures = >[]; - if (LocalSettings.instance.remoteFetchEnabled) { try { - final Set fileIds = {}; // if there are duplicates here server returns 400 + final Set fileIds = + {}; // if there are duplicates here server returns 400 // Try to find embeddings on the remote server for (final f in chunk) { fileIds.add(f.uploadedFileID!); } _logger.info('starting remote fetch for ${fileIds.length} files'); - final res = await RemoteFileMLService.instance.getFilessEmbedding(fileIds); + final res = + await RemoteFileMLService.instance.getFilessEmbedding(fileIds); _logger.info('fetched ${res.mlData.length} embeddings'); fetchedCount += res.mlData.length; final List faces = []; @@ -478,7 +494,8 @@ class FaceMlService { faces.add(f); } } - remoteFileIdToVersion[fileMl.fileID] = fileMl.faceEmbedding.version; + remoteFileIdToVersion[fileMl.fileID] = + fileMl.faceEmbedding.version; } if (res.noEmbeddingFileIDs.isNotEmpty) { _logger.info( @@ -495,7 +512,8 @@ class FaceMlService { for (final entry in remoteFileIdToVersion.entries) { alreadyIndexedFiles[entry.key] = entry.value; } - _logger.info('already indexed files ${remoteFileIdToVersion.length}'); + _logger + .info('already indexed files ${remoteFileIdToVersion.length}'); } catch (e, s) { _logger.severe("err while getting files embeddings", e, s); if (retryFetchCount < 1000) { @@ -519,6 +537,7 @@ class FaceMlService { } final smallerChunks = chunk.chunks(_fileDownloadLimit); for (final smallestChunk in smallerChunks) { + final futures = >[]; if (!await canUseHighBandwidth()) { _logger.info( 'stopping indexing because user is not connected to wifi', @@ -545,12 +564,22 @@ class FaceMlService { (previousValue, element) => previousValue + (element ? 1 : 0), ); fileAnalyzedCount += sumFutures; + + if (fileAnalyzedCount > _kcooldownLimit) { + _logger.info( + 'Reached ${cooldownCount * _kcooldownLimit + fileAnalyzedCount} indexed files, cooling down to prevent OS from killing the app', + ); + cooldownCount++; + fileAnalyzedCount -= _kcooldownLimit; + await Future.delayed(_kCooldownDuration); + _logger.info('cooldown done, continuing indexing'); + } } } stopwatch.stop(); _logger.info( - "`indexAllImages()` finished. Fetched $fetchedCount and analyzed $fileAnalyzedCount images, in ${stopwatch.elapsed.inSeconds} seconds (avg of ${stopwatch.elapsed.inSeconds / fileAnalyzedCount} seconds per image, skipped $fileSkippedCount images)", + "`indexAllImages()` finished. Fetched $fetchedCount and analyzed ${cooldownCount * _kcooldownLimit + fileAnalyzedCount} images, in ${stopwatch.elapsed.inSeconds} seconds (avg of ${stopwatch.elapsed.inSeconds / fileAnalyzedCount} seconds per image, skipped $fileSkippedCount images, $cooldownCount cooldowns)", ); _logStatus(); } catch (e, s) { @@ -578,9 +607,10 @@ class FaceMlService { _showClusteringIsHappening = true; // Get a sense of the total number of faces in the database - final int totalFaces = - await FaceMLDataDB.instance.getTotalFaceCount(minFaceScore: minFaceScore); - final fileIDToCreationTime = await FilesDB.instance.getFileIDToCreationTime(); + final int totalFaces = await FaceMLDataDB.instance + .getTotalFaceCount(minFaceScore: minFaceScore); + final fileIDToCreationTime = + await FilesDB.instance.getFileIDToCreationTime(); final startEmbeddingFetch = DateTime.now(); // read all embeddings final result = await FaceMLDataDB.instance.getFaceInfoForClustering( @@ -598,7 +628,8 @@ class FaceMlService { } // sort the embeddings based on file creation time, newest first allFaceInfoForClustering.sort((b, a) { - return fileIDToCreationTime[a.fileID]!.compareTo(fileIDToCreationTime[b.fileID]!); + return fileIDToCreationTime[a.fileID]! + .compareTo(fileIDToCreationTime[b.fileID]!); }); _logger.info( 'Getting and sorting embeddings took ${DateTime.now().difference(startEmbeddingFetch).inMilliseconds} ms for ${allFaceInfoForClustering.length} embeddings' @@ -654,7 +685,8 @@ class FaceMlService { } } - final clusteringResult = await FaceClusteringService.instance.predictLinearIsolate( + final clusteringResult = + await FaceClusteringService.instance.predictLinearIsolate( faceInfoForClustering.toSet(), fileIDToCreationTime: fileIDToCreationTime, offset: offset, @@ -665,13 +697,17 @@ class FaceMlService { return; } - await FaceMLDataDB.instance.updateFaceIdToClusterId(clusteringResult.newFaceIdToCluster); - await FaceMLDataDB.instance.clusterSummaryUpdate(clusteringResult.newClusterSummaries); + await FaceMLDataDB.instance + .updateFaceIdToClusterId(clusteringResult.newFaceIdToCluster); + await FaceMLDataDB.instance + .clusterSummaryUpdate(clusteringResult.newClusterSummaries); Bus.instance.fire(PeopleChangedEvent()); for (final faceInfo in faceInfoForClustering) { - faceInfo.clusterId ??= clusteringResult.newFaceIdToCluster[faceInfo.faceID]; + faceInfo.clusterId ??= + clusteringResult.newFaceIdToCluster[faceInfo.faceID]; } - for (final clusterUpdate in clusteringResult.newClusterSummaries.entries) { + for (final clusterUpdate + in clusteringResult.newClusterSummaries.entries) { oldClusterSummaries[clusterUpdate.key] = clusterUpdate.value; } _logger.info( @@ -687,7 +723,8 @@ class FaceMlService { } else { final clusterStartTime = DateTime.now(); // Cluster the embeddings using the linear clustering algorithm, returning a map from faceID to clusterID - final clusteringResult = await FaceClusteringService.instance.predictLinearIsolate( + final clusteringResult = + await FaceClusteringService.instance.predictLinearIsolate( allFaceInfoForClustering.toSet(), fileIDToCreationTime: fileIDToCreationTime, oldClusterSummaries: oldClusterSummaries, @@ -705,8 +742,10 @@ class FaceMlService { _logger.info( 'Updating ${clusteringResult.newFaceIdToCluster.length} FaceIDs with clusterIDs in the DB', ); - await FaceMLDataDB.instance.updateFaceIdToClusterId(clusteringResult.newFaceIdToCluster); - await FaceMLDataDB.instance.clusterSummaryUpdate(clusteringResult.newClusterSummaries); + await FaceMLDataDB.instance + .updateFaceIdToClusterId(clusteringResult.newFaceIdToCluster); + await FaceMLDataDB.instance + .clusterSummaryUpdate(clusteringResult.newClusterSummaries); Bus.instance.fire(PeopleChangedEvent()); _logger.info('Done updating FaceIDs with clusterIDs in the DB, in ' '${DateTime.now().difference(clusterDoneTime).inSeconds} seconds'); @@ -739,7 +778,8 @@ class FaceMlService { allLandmarksEqual = false; break; } - if (face.detection.landmarks.any((landmark) => landmark.x != landmark.y)) { + if (face.detection.landmarks + .any((landmark) => landmark.x != landmark.y)) { allLandmarksEqual = false; break; } @@ -748,7 +788,10 @@ class FaceMlService { debugPrint("Discarding remote embedding for fileID ${fileMl.fileID} " "because landmarks are equal"); debugPrint( - fileMl.faceEmbedding.faces.map((e) => e.detection.landmarks.toString()).toList().toString(), + fileMl.faceEmbedding.faces + .map((e) => e.detection.landmarks.toString()) + .toList() + .toString(), ); return true; } @@ -772,9 +815,11 @@ class FaceMlService { // disposeImageIsolateAfterUse: false, ); if (result == null) { - _logger.severe( - "Failed to analyze image with uploadedFileID: ${enteFile.uploadedFileID}", - ); + if (!_shouldPauseIndexingAndClustering) { + _logger.severe( + "Failed to analyze image with uploadedFileID: ${enteFile.uploadedFileID}", + ); + } return false; } final List faces = []; @@ -786,9 +831,11 @@ class FaceMlService { Face.empty(result.fileId, error: result.errorOccured), ); } else { - if (result.decodedImageSize.width == -1 || result.decodedImageSize.height == -1) { - _logger.severe("decodedImageSize is not stored correctly for image with " - "ID: ${enteFile.uploadedFileID}"); + if (result.decodedImageSize.width == -1 || + result.decodedImageSize.height == -1) { + _logger + .severe("decodedImageSize is not stored correctly for image with " + "ID: ${enteFile.uploadedFileID}"); _logger.info( "Using aligned image size for image with ID: ${enteFile.uploadedFileID}. This size is ${result.decodedImageSize.width}x${result.decodedImageSize.height} compared to size of ${enteFile.width}x${enteFile.height} in the metadata", ); @@ -873,7 +920,8 @@ class FaceMlService { _checkEnteFileForID(enteFile); await ensureInitialized(); - final String? filePath = await _getImagePathForML(enteFile, typeOfData: FileDataForML.fileData); + final String? filePath = + await _getImagePathForML(enteFile, typeOfData: FileDataForML.fileData); if (filePath == null) { _logger.severe( @@ -892,13 +940,17 @@ class FaceMlService { { "enteFileID": enteFile.uploadedFileID ?? -1, "filePath": filePath, - "faceDetectionAddress": FaceDetectionService.instance.sessionAddress, - "faceEmbeddingAddress": FaceEmbeddingService.instance.sessionAddress, + "faceDetectionAddress": + FaceDetectionService.instance.sessionAddress, + "faceEmbeddingAddress": + FaceEmbeddingService.instance.sessionAddress, } ), ) as String?; if (resultJsonString == null) { - _logger.severe('Analyzing image in isolate is giving back null'); + if (!_shouldPauseIndexingAndClustering) { + _logger.severe('Analyzing image in isolate is giving back null'); + } return null; } result = FaceMlResult.fromJsonString(resultJsonString); @@ -947,7 +999,8 @@ class FaceMlService { stopwatch.reset(); // Get the faces - final List faceDetectionResult = await FaceMlService.detectFacesSync( + final List faceDetectionResult = + await FaceMlService.detectFacesSync( image, imgByteData, faceDetectionAddress, @@ -968,7 +1021,8 @@ class FaceMlService { stopwatch.reset(); // Align the faces - final Float32List faceAlignmentResult = await FaceMlService.alignFacesSync( + final Float32List faceAlignmentResult = + await FaceMlService.alignFacesSync( image, imgByteData, faceDetectionResult, @@ -1035,8 +1089,9 @@ class FaceMlService { } } if (file == null) { - _logger - .warning("Could not get file for $enteFile of type ${enteFile.fileType.toString()}"); + _logger.warning( + "Could not get file for $enteFile of type ${enteFile.fileType.toString()}", + ); imagePath = null; break; } @@ -1088,7 +1143,8 @@ class FaceMlService { }) async { try { // Get the bounding boxes of the faces - final (List faces, dataSize) = await FaceDetectionService.predictSync( + final (List faces, dataSize) = + await FaceDetectionService.predictSync( image, imageByteData, interpreterAddress, @@ -1198,7 +1254,8 @@ class FaceMlService { } bool _skipAnalysisEnteFile(EnteFile enteFile, Map indexedFileIds) { - if (_isIndexingOrClusteringRunning == false || _mlControllerStatus == false) { + if (_isIndexingOrClusteringRunning == false || + _mlControllerStatus == false) { return true; } // Skip if the file is not uploaded or not owned by the user @@ -1212,7 +1269,8 @@ class FaceMlService { // Skip if the file is already analyzed with the latest ml version final id = enteFile.uploadedFileID!; - return indexedFileIds.containsKey(id) && indexedFileIds[id]! >= faceMlVersion; + return indexedFileIds.containsKey(id) && + indexedFileIds[id]! >= faceMlVersion; } bool _cannotRunMLFunction({String function = ""}) { diff --git a/mobile/lib/services/machine_learning/machine_learning_controller.dart b/mobile/lib/services/machine_learning/machine_learning_controller.dart index 1ecb053f01..6d18d57199 100644 --- a/mobile/lib/services/machine_learning/machine_learning_controller.dart +++ b/mobile/lib/services/machine_learning/machine_learning_controller.dart @@ -18,41 +18,39 @@ class MachineLearningController { static const kMaximumTemperature = 42; // 42 degree celsius static const kMinimumBatteryLevel = 20; // 20% - static const kDefaultInteractionTimeout = Duration(seconds: 15); + final kDefaultInteractionTimeout = Duration(seconds: Platform.isIOS ? 5 : 15); static const kUnhealthyStates = ["over_heat", "over_voltage", "dead"]; bool _isDeviceHealthy = true; bool _isUserInteracting = true; bool _canRunML = false; + bool mlInteractionOverride = false; late Timer _userInteractionTimer; bool get isDeviceHealthy => _isDeviceHealthy; void init() { _logger.info('init called'); - if (Platform.isAndroid) { - _startInteractionTimer(); - BatteryInfoPlugin() - .androidBatteryInfoStream - .listen((AndroidBatteryInfo? batteryInfo) { - _onAndroidBatteryStateUpdate(batteryInfo); - }); - } + _startInteractionTimer(kDefaultInteractionTimeout); if (Platform.isIOS) { BatteryInfoPlugin() .iosBatteryInfoStream .listen((IosBatteryInfo? batteryInfo) { _oniOSBatteryStateUpdate(batteryInfo); }); + } + if (Platform.isAndroid) { + BatteryInfoPlugin() + .androidBatteryInfoStream + .listen((AndroidBatteryInfo? batteryInfo) { + _onAndroidBatteryStateUpdate(batteryInfo); + }); } _fireControlEvent(); _logger.info('init done'); } void onUserInteraction() { - if (Platform.isIOS) { - return; - } if (!_isUserInteracting) { _logger.info("User is interacting with the app"); _isUserInteracting = true; @@ -61,19 +59,28 @@ class MachineLearningController { _resetTimer(); } + bool _canRunGivenUserInteraction() { + return !_isUserInteracting || mlInteractionOverride; + } + + void forceOverrideML({required bool turnOn}) { + _logger.info("Forcing to turn on ML: $turnOn"); + mlInteractionOverride = turnOn; + _fireControlEvent(); + } + void _fireControlEvent() { - final shouldRunML = - _isDeviceHealthy && (Platform.isAndroid ? !_isUserInteracting : true); + final shouldRunML = _isDeviceHealthy && _canRunGivenUserInteraction(); if (shouldRunML != _canRunML) { _canRunML = shouldRunML; _logger.info( - "Firing event with $shouldRunML, device health: $_isDeviceHealthy and user interaction: $_isUserInteracting", + "Firing event: $shouldRunML (device health: $_isDeviceHealthy, user interaction: $_isUserInteracting, mlInteractionOverride: $mlInteractionOverride)", ); Bus.instance.fire(MachineLearningControlEvent(shouldRunML)); } } - void _startInteractionTimer({Duration timeout = kDefaultInteractionTimeout}) { + void _startInteractionTimer(Duration timeout) { _userInteractionTimer = Timer(timeout, () { _logger.info("User is not interacting with the app"); _isUserInteracting = false; @@ -83,7 +90,7 @@ class MachineLearningController { void _resetTimer() { _userInteractionTimer.cancel(); - _startInteractionTimer(); + _startInteractionTimer(kDefaultInteractionTimeout); } void _onAndroidBatteryStateUpdate(AndroidBatteryInfo? batteryInfo) { diff --git a/mobile/lib/services/machine_learning/semantic_search/semantic_search_service.dart b/mobile/lib/services/machine_learning/semantic_search/semantic_search_service.dart index 1384750811..d65c67aba3 100644 --- a/mobile/lib/services/machine_learning/semantic_search/semantic_search_service.dart +++ b/mobile/lib/services/machine_learning/semantic_search/semantic_search_service.dart @@ -131,11 +131,15 @@ class SemanticSearchService { _isSyncing = false; } + bool isMagicSearchEnabledAndReady() { + return LocalSettings.instance.hasEnabledMagicSearch() && + _frameworkInitialization.isCompleted; + } + // searchScreenQuery should only be used for the user initiate query on the search screen. // If there are multiple call tho this method, then for all the calls, the result will be the same as the last query. Future<(String, List)> searchScreenQuery(String query) async { - if (!LocalSettings.instance.hasEnabledMagicSearch() || - !_frameworkInitialization.isCompleted) { + if (!isMagicSearchEnabledAndReady()) { return (query, []); } // If there's an ongoing request, just update the last query and return its future. @@ -144,7 +148,7 @@ class SemanticSearchService { return _searchScreenRequest!; } else { // No ongoing request, start a new search. - _searchScreenRequest = _getMatchingFiles(query).then((result) { + _searchScreenRequest = getMatchingFiles(query).then((result) { // Search completed, reset the ongoing request. _searchScreenRequest = null; // If there was a new query during the last search, start a new search with the last query. @@ -236,18 +240,24 @@ class SemanticSearchService { _queue.clear(); } - Future> _getMatchingFiles(String query) async { + Future> getMatchingFiles( + String query, { + double? scoreThreshold, + }) async { final textEmbedding = await _getTextEmbedding(query); - final queryResults = await _getScores(textEmbedding); + final queryResults = + await _getScores(textEmbedding, scoreThreshold: scoreThreshold); final filesMap = await FilesDB.instance .getFilesFromIDs(queryResults.map((e) => e.id).toList()); - final results = []; final ignoredCollections = CollectionsService.instance.getHiddenCollectionIds(); + final deletedEntries = []; + final results = []; + for (final result in queryResults) { final file = filesMap[result.id]; if (file != null && !ignoredCollections.contains(file.collectionID)) { @@ -355,13 +365,17 @@ class SemanticSearchService { } } - Future> _getScores(List textEmbedding) async { + Future> _getScores( + List textEmbedding, { + double? scoreThreshold, + }) async { final startTime = DateTime.now(); final List queryResults = await _computer.compute( computeBulkScore, param: { "imageEmbeddings": _cachedEmbeddings, "textEmbedding": textEmbedding, + "scoreThreshold": scoreThreshold, }, taskName: "computeBulkScore", ); @@ -402,12 +416,14 @@ List computeBulkScore(Map args) { final queryResults = []; final imageEmbeddings = args["imageEmbeddings"] as List; final textEmbedding = args["textEmbedding"] as List; + final scoreThreshold = + args["scoreThreshold"] ?? SemanticSearchService.kScoreThreshold; for (final imageEmbedding in imageEmbeddings) { final score = computeScore( imageEmbedding.embedding, textEmbedding, ); - if (score >= SemanticSearchService.kScoreThreshold) { + if (score >= scoreThreshold) { queryResults.add(QueryResult(imageEmbedding.fileID, score)); } } @@ -422,7 +438,8 @@ double computeScore(List imageEmbedding, List textEmbedding) { "The two embeddings should have the same length", ); double score = 0; - for (int index = 0; index < imageEmbedding.length; index++) { + final length = imageEmbedding.length; + for (int index = 0; index < length; index++) { score += imageEmbedding[index] * textEmbedding[index]; } return score; diff --git a/mobile/lib/services/passkey_service.dart b/mobile/lib/services/passkey_service.dart index 07a22f5a6d..37d7aa5392 100644 --- a/mobile/lib/services/passkey_service.dart +++ b/mobile/lib/services/passkey_service.dart @@ -42,7 +42,7 @@ class PasskeyService { Future openPasskeyPage(BuildContext context) async { try { final jwtToken = await getJwtToken(); - final url = "https://accounts.ente.io/account-handoff?token=$jwtToken"; + final url = "https://accounts.ente.io/passkeys?token=$jwtToken"; await launchUrlString( url, mode: LaunchMode.externalApplication, diff --git a/mobile/lib/services/remote_assets_service.dart b/mobile/lib/services/remote_assets_service.dart index 1e2cb3b6df..487f2f8b11 100644 --- a/mobile/lib/services/remote_assets_service.dart +++ b/mobile/lib/services/remote_assets_service.dart @@ -18,10 +18,10 @@ class RemoteAssetsService { static final RemoteAssetsService instance = RemoteAssetsService._privateConstructor(); - Future getAsset(String remotePath) async { + Future getAsset(String remotePath, {bool refetch = false}) async { final path = await _getLocalPath(remotePath); final file = File(path); - if (await file.exists()) { + if (await file.exists() && !refetch) { _logger.info("Returning cached file for $remotePath"); return file; } else { diff --git a/mobile/lib/services/search_service.dart b/mobile/lib/services/search_service.dart index d15eddb718..e55684ed9b 100644 --- a/mobile/lib/services/search_service.dart +++ b/mobile/lib/services/search_service.dart @@ -1,3 +1,4 @@ +import "dart:convert"; import "dart:math"; import "package:flutter/cupertino.dart"; @@ -26,11 +27,13 @@ import 'package:photos/models/search/album_search_result.dart'; import 'package:photos/models/search/generic_search_result.dart'; import "package:photos/models/search/search_constants.dart"; import "package:photos/models/search/search_types.dart"; +import "package:photos/service_locator.dart"; import 'package:photos/services/collections_service.dart'; import "package:photos/services/location_service.dart"; import "package:photos/services/machine_learning/face_ml/face_filtering/face_filtering_constants.dart"; import "package:photos/services/machine_learning/face_ml/person/person_service.dart"; import 'package:photos/services/machine_learning/semantic_search/semantic_search_service.dart'; +import "package:photos/services/remote_assets_service.dart"; import "package:photos/states/location_screen_state.dart"; import "package:photos/ui/viewer/location/add_location_sheet.dart"; import "package:photos/ui/viewer/location/location_screen.dart"; @@ -46,6 +49,9 @@ class SearchService { final _logger = Logger((SearchService).toString()); final _collectionService = CollectionsService.instance; static const _maximumResultsLimit = 20; + static const _kMagicPromptsDataUrl = "https://discover.ente.io/v1.json"; + + var magicPromptsData = []; SearchService._privateConstructor(); @@ -57,6 +63,17 @@ class SearchService { _cachedFilesFuture = null; _cachedHiddenFilesFuture = null; }); + if (flagService.internalUser) { + _loadMagicPrompts(); + } + } + + Future _loadMagicPrompts() async { + final file = await RemoteAssetsService.instance + .getAsset(_kMagicPromptsDataUrl, refetch: true); + + final json = jsonDecode(await file.readAsString()); + magicPromptsData = json["prompts"]; } Set ignoreCollections() { @@ -174,6 +191,29 @@ class SearchService { return searchResults; } + Future> getMagicSectionResutls() async { + if (!SemanticSearchService.instance.isMagicSearchEnabledAndReady()) { + return []; + } + final searchResuts = []; + for (Map magicPrompt in magicPromptsData) { + final files = await SemanticSearchService.instance.getMatchingFiles( + magicPrompt["prompt"], + scoreThreshold: magicPrompt["minimumScore"], + ); + if (files.isNotEmpty) { + searchResuts.add( + GenericSearchResult( + ResultType.magic, + magicPrompt["title"], + files, + ), + ); + } + } + return searchResuts; + } + Future> getRandomMomentsSearchResults( BuildContext context, ) async { diff --git a/mobile/lib/services/update_service.dart b/mobile/lib/services/update_service.dart index da01de828d..3568c873de 100644 --- a/mobile/lib/services/update_service.dart +++ b/mobile/lib/services/update_service.dart @@ -16,7 +16,7 @@ class UpdateService { static final UpdateService instance = UpdateService._privateConstructor(); static const kUpdateAvailableShownTimeKey = "update_available_shown_time_key"; static const changeLogVersionKey = "update_change_log_key"; - static const currentChangeLogVersion = 19; + static const currentChangeLogVersion = 20; LatestVersionInfo? _latestVersion; final _logger = Logger("UpdateService"); diff --git a/mobile/lib/services/user_service.dart b/mobile/lib/services/user_service.dart index 44e098567c..a6cb656206 100644 --- a/mobile/lib/services/user_service.dart +++ b/mobile/lib/services/user_service.dart @@ -16,6 +16,7 @@ import "package:photos/events/account_configured_event.dart"; import 'package:photos/events/two_factor_status_change_event.dart'; import 'package:photos/events/user_details_changed_event.dart'; import "package:photos/generated/l10n.dart"; +import "package:photos/l10n/l10n.dart"; import "package:photos/models/account/two_factor.dart"; import "package:photos/models/api/user/srp.dart"; import 'package:photos/models/delete_account.dart'; @@ -308,23 +309,67 @@ class UserService { } } - Future onPassKeyVerified(BuildContext context, Map response) async { - final userPassword = Configuration.instance.getVolatilePassword(); - if (userPassword == null) throw Exception("volatile password is null"); - - await _saveConfiguration(response); - - if (Configuration.instance.getEncryptedToken() != null) { - await Configuration.instance.decryptSecretsAndGetKeyEncKey( - userPassword, - Configuration.instance.getKeyAttributes()!, + Future getTokenForPasskeySession(String sessionID) async { + try { + final response = await _dio.get( + "${_config.getHttpEndpoint()}/users/two-factor/passkeys/get-token", + queryParameters: { + "sessionID": sessionID, + }, ); - } else { - throw Exception("unexpected response during passkey verification"); + return response.data; + } on DioError catch (e) { + if (e.response != null) { + if (e.response!.statusCode == 404 || e.response!.statusCode == 410) { + throw PassKeySessionExpiredError(); + } + if (e.response!.statusCode == 400) { + throw PassKeySessionNotVerifiedError(); + } + } + rethrow; + } catch (e, s) { + _logger.severe("unexpected error", e, s); + rethrow; } + } - Navigator.of(context).popUntil((route) => route.isFirst); - Bus.instance.fire(AccountConfiguredEvent()); + Future onPassKeyVerified(BuildContext context, Map response) async { + final ProgressDialog dialog = + createProgressDialog(context, context.l10n.pleaseWait); + await dialog.show(); + try { + final userPassword = Configuration.instance.getVolatilePassword(); + await _saveConfiguration(response); + if (userPassword == null) { + await dialog.hide(); + // ignore: unawaited_futures + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (BuildContext context) { + return const PasswordReentryPage(); + }, + ), + (route) => route.isFirst, + ); + } else { + if (Configuration.instance.getEncryptedToken() != null) { + await Configuration.instance.decryptSecretsAndGetKeyEncKey( + userPassword, + Configuration.instance.getKeyAttributes()!, + ); + } else { + throw Exception("unexpected response during passkey verification"); + } + await dialog.hide(); + Navigator.of(context).popUntil((route) => route.isFirst); + Bus.instance.fire(AccountConfiguredEvent()); + } + } catch (e) { + _logger.severe(e); + await dialog.hide(); + await showGenericErrorDialog(context: context, error: e); + } } Future verifyEmail( @@ -349,10 +394,14 @@ class UserService { await dialog.hide(); if (response.statusCode == 200) { Widget page; + final String passkeySessionID = response.data["passkeySessionID"]; final String twoFASessionID = response.data["twoFactorSessionID"]; + if (twoFASessionID.isNotEmpty) { await setTwoFactor(value: true); page = TwoFactorAuthenticationPage(twoFASessionID); + } else if (passkeySessionID.isNotEmpty) { + page = PasskeyPage(passkeySessionID); } else { await _saveConfiguration(response); if (Configuration.instance.getEncryptedToken() != null) { diff --git a/mobile/lib/states/all_sections_examples_state.dart b/mobile/lib/states/all_sections_examples_state.dart index a40ecd9255..716de8db56 100644 --- a/mobile/lib/states/all_sections_examples_state.dart +++ b/mobile/lib/states/all_sections_examples_state.dart @@ -88,9 +88,6 @@ class _AllSectionsExamplesProviderState _logger.info("'_debounceTimer: reloading all sections in search tab"); final allSectionsExamples = >>[]; for (SectionType sectionType in SectionType.values) { - if (sectionType == SectionType.content) { - continue; - } allSectionsExamples.add( sectionType.getData(context, limit: kSearchSectionLimit), ); diff --git a/mobile/lib/ui/account/passkey_page.dart b/mobile/lib/ui/account/passkey_page.dart index 08b2472b37..c6e3e00f0f 100644 --- a/mobile/lib/ui/account/passkey_page.dart +++ b/mobile/lib/ui/account/passkey_page.dart @@ -3,13 +3,15 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; import 'package:photos/core/configuration.dart'; -import "package:photos/generated/l10n.dart"; +import "package:photos/core/errors.dart"; +import "package:photos/l10n/l10n.dart"; import "package:photos/models/account/two_factor.dart"; import 'package:photos/services/user_service.dart'; import "package:photos/ui/components/buttons/button_widget.dart"; import "package:photos/ui/components/models/button_type.dart"; import "package:photos/utils/dialog_util.dart"; -import 'package:uni_links/uni_links.dart'; +import "package:photos/utils/toast_util.dart"; +import "package:uni_links/uni_links.dart"; import 'package:url_launcher/url_launcher_string.dart'; class PasskeyPage extends StatefulWidget { @@ -17,8 +19,8 @@ class PasskeyPage extends StatefulWidget { const PasskeyPage( this.sessionID, { - Key? key, - }) : super(key: key); + super.key, + }); @override State createState() => _PasskeyPageState(); @@ -41,13 +43,38 @@ class _PasskeyPageState extends State { Future launchPasskey() async { await launchUrlString( - "https://accounts.ente.io/passkeys/flow?" + "https://accounts.ente.io/passkeys/verify?" "passkeySessionID=${widget.sessionID}" - "&redirect=ente://passkey", + "&redirect=ente://passkey" + "&clientPackage=io.ente.photos", mode: LaunchMode.externalApplication, ); } + Future checkStatus() async { + late dynamic response; + try { + response = await UserService.instance + .getTokenForPasskeySession(widget.sessionID); + } on PassKeySessionNotVerifiedError { + showToast(context, context.l10n.passKeyPendingVerification); + return; + } on PassKeySessionExpiredError { + await showErrorDialog( + context, + context.l10n.loginSessionExpired, + context.l10n.loginSessionExpiredDetails, + ); + Navigator.of(context).pop(); + return; + } catch (e, s) { + _logger.severe("failed to check status", e, s); + showGenericErrorDialog(context: context, error: e).ignore(); + return; + } + await UserService.instance.onPassKeyVerified(context, response); + } + Future _handleDeeplink(String? link) async { if (!context.mounted || Configuration.instance.hasConfiguredAccount() || @@ -59,8 +86,20 @@ class _PasskeyPageState extends State { } try { if (mounted && link.toLowerCase().startsWith("ente://passkey")) { - final String? uri = Uri.parse(link).queryParameters['response']; - String base64String = uri!.toString(); + if (Configuration.instance.isLoggedIn()) { + _logger.info('ignored deeplink: already configured'); + showToast(context, 'Account is already configured.'); + return; + } + final parsedUri = Uri.parse(link); + final sessionID = parsedUri.queryParameters['passkeySessionID']; + if (sessionID != widget.sessionID) { + showToast(context, "Session ID mismatch"); + _logger.warning('ignored deeplink: sessionID mismatch'); + return; + } + final String? authResponse = parsedUri.queryParameters['response']; + String base64String = authResponse!.toString(); while (base64String.length % 4 != 0) { base64String += '='; } @@ -89,10 +128,11 @@ class _PasskeyPageState extends State { @override Widget build(BuildContext context) { + final l10n = context.l10n; return Scaffold( appBar: AppBar( title: Text( - S.of(context).passkeyAuthTitle, + l10n.passkeyAuthTitle, ), ), body: _getBody(), @@ -107,7 +147,7 @@ class _PasskeyPageState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ Text( - S.of(context).waitingForVerification, + context.l10n.waitingForVerification, style: const TextStyle( height: 1.4, fontSize: 16, @@ -116,9 +156,23 @@ class _PasskeyPageState extends State { const SizedBox(height: 16), ButtonWidget( buttonType: ButtonType.primary, - labelText: S.of(context).verifyPasskey, + labelText: context.l10n.tryAgain, onTap: () => launchPasskey(), ), + const SizedBox(height: 16), + ButtonWidget( + buttonType: ButtonType.secondary, + labelText: context.l10n.checkStatus, + onTap: () async { + try { + await checkStatus(); + } catch (e) { + debugPrint('failed to check status %e'); + showGenericErrorDialog(context: context, error: e).ignore(); + } + }, + shouldSurfaceExecutionStates: true, + ), const Padding(padding: EdgeInsets.all(30)), GestureDetector( behavior: HitTestBehavior.opaque, @@ -133,7 +187,7 @@ class _PasskeyPageState extends State { padding: const EdgeInsets.all(10), child: Center( child: Text( - S.of(context).recoverAccount, + context.l10n.recoverAccount, style: const TextStyle( decoration: TextDecoration.underline, fontSize: 12, diff --git a/mobile/lib/ui/actions/collection/collection_sharing_actions.dart b/mobile/lib/ui/actions/collection/collection_sharing_actions.dart index 3328722dbe..c4f23df419 100644 --- a/mobile/lib/ui/actions/collection/collection_sharing_actions.dart +++ b/mobile/lib/ui/actions/collection/collection_sharing_actions.dart @@ -110,12 +110,6 @@ class CollectionActions { BuildContext context, List files, ) async { - final dialog = createProgressDialog( - context, - S.of(context).creatingLink, - isDismissible: true, - ); - await dialog.show(); try { // create album with emptyName, use collectionCreationTime on UI to // show name @@ -143,10 +137,8 @@ class CollectionActions { await collectionsService.addOrCopyToCollection(collection.id, files); logger.finest("creating public link for the newly created album"); await CollectionsService.instance.createShareUrl(collection); - await dialog.hide(); return collection; } catch (e, s) { - await dialog.hide(); await showGenericErrorDialog(context: context, error: e); logger.severe("Failing to create link for selected files", e, s); } diff --git a/mobile/lib/ui/components/bottom_action_bar/selection_action_button_widget.dart b/mobile/lib/ui/components/bottom_action_bar/selection_action_button_widget.dart index 5ca6a25dcc..12a744848e 100644 --- a/mobile/lib/ui/components/bottom_action_bar/selection_action_button_widget.dart +++ b/mobile/lib/ui/components/bottom_action_bar/selection_action_button_widget.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import "package:flutter/material.dart"; import "package:photos/theme/ente_theme.dart"; @@ -89,11 +91,41 @@ class __BodyState extends State<_Body> { mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ - Icon( - widget.icon, - size: 24, - color: getEnteColorScheme(context).textMuted, - ), + if (widget.icon == Icons.navigation_rounded) + Transform.rotate( + angle: math.pi / 2, + child: Icon( + widget.icon, + size: 24, + color: getEnteColorScheme(context).primary300, + shadows: const [ + BoxShadow( + color: Color.fromARGB(12, 0, 179, 60), + offset: Offset(0, 2.51), + blurRadius: 5.02, + spreadRadius: 0, + ), + BoxShadow( + color: Color.fromARGB(24, 0, 179, 60), + offset: Offset(0, 1.25), + blurRadius: 3.76, + spreadRadius: 0, + ), + BoxShadow( + color: Color.fromARGB(24, 0, 179, 60), + offset: Offset(0, 0.63), + blurRadius: 1.88, + spreadRadius: 0, + ), + ], + ), + ) + else + Icon( + widget.icon, + size: 24, + color: getEnteColorScheme(context).textMuted, + ), const SizedBox(height: 4), Text( widget.labelText, diff --git a/mobile/lib/ui/components/title_bar_title_widget.dart b/mobile/lib/ui/components/title_bar_title_widget.dart index a685ea0456..e838849ef3 100644 --- a/mobile/lib/ui/components/title_bar_title_widget.dart +++ b/mobile/lib/ui/components/title_bar_title_widget.dart @@ -6,11 +6,13 @@ class TitleBarTitleWidget extends StatelessWidget { final bool isTitleH2; final IconData? icon; final VoidCallback? onTap; + final String? heroTag; const TitleBarTitleWidget({ this.title, this.isTitleH2 = false, this.icon, this.onTap, + this.heroTag, super.key, }); @@ -51,7 +53,10 @@ class TitleBarTitleWidget extends StatelessWidget { maxLines: 1, ); } - return GestureDetector(onTap: onTap, child: widget); + return GestureDetector( + onTap: onTap, + child: heroTag != null ? Hero(tag: heroTag!, child: widget) : widget, + ); } return const SizedBox.shrink(); diff --git a/mobile/lib/ui/notification/update/change_log_page.dart b/mobile/lib/ui/notification/update/change_log_page.dart index 90430fae25..d301cacb03 100644 --- a/mobile/lib/ui/notification/update/change_log_page.dart +++ b/mobile/lib/ui/notification/update/change_log_page.dart @@ -38,10 +38,10 @@ class _ChangeLogPageState extends State { ), Container( alignment: Alignment.centerLeft, - child: const Padding( - padding: EdgeInsets.symmetric(horizontal: 16.0), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), child: TitleBarTitleWidget( - title: "What's new", + title: S.of(context).whatsNew, ), ), ), @@ -119,20 +119,20 @@ class _ChangeLogPageState extends State { final List items = []; items.addAll([ ChangeLogEntry( - "Cast albums to TV ✨", - "View a slideshow of your albums on any big screen! Open an album and click on the Cast button to get started.", + "Send links ✨", + 'Introducing a beautiful way to share photos in original quality, end-to-end encrypted. Select your photos and click on "Send link" to see the magic!', ), ChangeLogEntry( - "Organize shared photos", - "You can now add shared items to your favorites or to any of your personal albums. Ente will create a copy that is fully owned by you and can be organized to your liking.", + "Video editor", + "Crop, clip and flip your videos, with Ente's in-built video editor. The editor works fully offline and will help with all your basic editing tasks.", ), ChangeLogEntry( - "Download multiple items", - "You can now download multiple items to your gallery at once. Select the items you want to download and click on the download button.", + "Passkeys", + "Now secure your Ente account with passkeys or hardware keys. You can add your keys within Settings > Security > Passkeys.", ), ChangeLogEntry( - "Performance improvements", - "This release also brings in major changes that should improve responsiveness. If you discover room for improvement, please let us know!", + "View large files", + "Find those items that take up the most amount of storage, and easily declutter your library. Open Settings > Backup > Free up space to learn more.", ), ]); diff --git a/mobile/lib/ui/settings/backup/backup_section_widget.dart b/mobile/lib/ui/settings/backup/backup_section_widget.dart index 81013f653e..183b79b203 100644 --- a/mobile/lib/ui/settings/backup/backup_section_widget.dart +++ b/mobile/lib/ui/settings/backup/backup_section_widget.dart @@ -1,30 +1,14 @@ -import "dart:async"; -import 'dart:io'; - import 'package:flutter/material.dart'; import "package:photos/generated/l10n.dart"; -import 'package:photos/models/backup_status.dart'; -import 'package:photos/models/duplicate_files.dart'; -import 'package:photos/services/deduplication_service.dart'; -import 'package:photos/services/sync_service.dart'; -import 'package:photos/services/update_service.dart'; import 'package:photos/theme/ente_theme.dart'; -import "package:photos/ui/components/buttons/button_widget.dart"; import "package:photos/ui/components/captioned_text_widget.dart"; -import "package:photos/ui/components/dialog_widget.dart"; import 'package:photos/ui/components/expandable_menu_item_widget.dart'; import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart'; -import "package:photos/ui/components/models/button_type.dart"; import 'package:photos/ui/settings/backup/backup_folder_selection_page.dart'; import 'package:photos/ui/settings/backup/backup_settings_screen.dart'; +import "package:photos/ui/settings/backup/free_space_options.dart"; import 'package:photos/ui/settings/common_settings.dart'; -import 'package:photos/ui/tools/deduplicate_page.dart'; -import "package:photos/ui/tools/free_space_page.dart"; -import 'package:photos/utils/data_util.dart'; -import 'package:photos/utils/dialog_util.dart'; -import "package:photos/utils/local_settings.dart"; import 'package:photos/utils/navigation_util.dart'; -import 'package:photos/utils/toast_util.dart'; class BackupSectionWidget extends StatefulWidget { const BackupSectionWidget({Key? key}) : super(key: key); @@ -84,71 +68,17 @@ class BackupSectionWidgetState extends State { [ MenuItemWidget( captionedTextWidget: CaptionedTextWidget( - title: S.of(context).freeUpDeviceSpace, + title: S.of(context).freeUpSpace, ), pressedColor: getEnteColorScheme(context).fillFaint, trailingIcon: Icons.chevron_right_outlined, trailingIconIsMuted: true, showOnlyLoadingState: true, onTap: () async { - BackupStatus status; - try { - status = await SyncService.instance.getBackupStatus(); - } catch (e) { - await showGenericErrorDialog(context: context, error: e); - return; - } - - if (status.localIDs.isEmpty) { - // ignore: unawaited_futures - showErrorDialog( - context, - S.of(context).allClear, - S.of(context).noDeviceThatCanBeDeleted, - ); - } else { - final bool? result = - await routeToPage(context, FreeSpacePage(status)); - if (result == true) { - _showSpaceFreedDialog(status); - } - } - }, - ), - sectionOptionSpacing, - MenuItemWidget( - captionedTextWidget: CaptionedTextWidget( - title: S.of(context).removeDuplicates, - ), - pressedColor: getEnteColorScheme(context).fillFaint, - trailingIcon: Icons.chevron_right_outlined, - trailingIconIsMuted: true, - showOnlyLoadingState: true, - onTap: () async { - List duplicates; - try { - duplicates = - await DeduplicationService.instance.getDuplicateFiles(); - } catch (e) { - await showGenericErrorDialog(context: context, error: e); - return; - } - - if (duplicates.isEmpty) { - unawaited( - showErrorDialog( - context, - S.of(context).noDuplicates, - S.of(context).youveNoDuplicateFilesThatCanBeCleared, - ), - ); - } else { - final DeduplicationResult? result = - await routeToPage(context, DeduplicatePage(duplicates)); - if (result != null) { - _showDuplicateFilesDeletedDialog(result); - } - } + await routeToPage( + context, + const FreeUpSpaceOptionsScreen(), + ); }, ), sectionOptionSpacing, @@ -158,73 +88,4 @@ class BackupSectionWidgetState extends State { children: sectionOptions, ); } - - void _showSpaceFreedDialog(BackupStatus status) { - if (LocalSettings.instance.shouldPromptToRateUs()) { - LocalSettings.instance.setRateUsShownCount( - LocalSettings.instance.getRateUsShownCount() + 1, - ); - showChoiceDialog( - context, - title: S.of(context).success, - body: - S.of(context).youHaveSuccessfullyFreedUp(formatBytes(status.size)), - firstButtonLabel: S.of(context).rateUs, - firstButtonOnTap: () async { - await UpdateService.instance.launchReviewUrl(); - }, - firstButtonType: ButtonType.primary, - secondButtonLabel: S.of(context).ok, - secondButtonOnTap: () async { - if (Platform.isIOS) { - showToast(context, S.of(context).remindToEmptyDeviceTrash); - } - }, - ); - } else { - showDialogWidget( - context: context, - title: S.of(context).success, - body: - S.of(context).youHaveSuccessfullyFreedUp(formatBytes(status.size)), - icon: Icons.download_done_rounded, - isDismissible: true, - buttons: [ - ButtonWidget( - buttonType: ButtonType.neutral, - labelText: S.of(context).ok, - isInAlert: true, - onTap: () async { - if (Platform.isIOS) { - showToast(context, S.of(context).remindToEmptyDeviceTrash); - } - }, - ), - ], - ); - } - } - - void _showDuplicateFilesDeletedDialog(DeduplicationResult result) { - showChoiceDialog( - context, - title: S.of(context).sparkleSuccess, - body: S.of(context).duplicateFileCountWithStorageSaved( - result.count, - formatBytes(result.size), - ), - firstButtonLabel: S.of(context).rateUs, - firstButtonOnTap: () async { - await UpdateService.instance.launchReviewUrl(); - }, - firstButtonType: ButtonType.primary, - secondButtonLabel: S.of(context).ok, - secondButtonOnTap: () async { - showShortToast( - context, - S.of(context).remindToEmptyEnteTrash, - ); - }, - ); - } } diff --git a/mobile/lib/ui/settings/backup/free_space_options.dart b/mobile/lib/ui/settings/backup/free_space_options.dart new file mode 100644 index 0000000000..1479dfdba8 --- /dev/null +++ b/mobile/lib/ui/settings/backup/free_space_options.dart @@ -0,0 +1,297 @@ +import "dart:async"; +import "dart:io"; + +import 'package:flutter/material.dart'; +import "package:photos/generated/l10n.dart"; +import "package:photos/models/backup_status.dart"; +import "package:photos/models/duplicate_files.dart"; +import "package:photos/services/deduplication_service.dart"; +import "package:photos/services/sync_service.dart"; +import "package:photos/services/update_service.dart"; +import 'package:photos/theme/ente_theme.dart'; +import 'package:photos/ui/components/buttons/button_widget.dart'; +import 'package:photos/ui/components/buttons/icon_button_widget.dart'; +import 'package:photos/ui/components/captioned_text_widget.dart'; +import "package:photos/ui/components/dialog_widget.dart"; +import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart'; +import "package:photos/ui/components/menu_section_description_widget.dart"; +import "package:photos/ui/components/models/button_type.dart"; +import 'package:photos/ui/components/title_bar_title_widget.dart'; +import 'package:photos/ui/components/title_bar_widget.dart'; +import "package:photos/ui/tools/deduplicate_page.dart"; +import "package:photos/ui/tools/free_space_page.dart"; +import "package:photos/ui/viewer/gallery/large_files_page.dart"; +import "package:photos/utils/data_util.dart"; +import "package:photos/utils/dialog_util.dart"; +import 'package:photos/utils/local_settings.dart'; +import 'package:photos/utils/navigation_util.dart'; +import "package:photos/utils/toast_util.dart"; + +class FreeUpSpaceOptionsScreen extends StatefulWidget { + const FreeUpSpaceOptionsScreen({super.key}); + + @override + State createState() => + _FreeUpSpaceOptionsScreenState(); +} + +class _FreeUpSpaceOptionsScreenState extends State { + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + final colorScheme = getEnteColorScheme(context); + return Scaffold( + body: CustomScrollView( + primary: false, + slivers: [ + TitleBarWidget( + flexibleSpaceTitle: TitleBarTitleWidget( + title: S.of(context).freeUpSpace, + ), + actionIcons: [ + IconButtonWidget( + icon: Icons.close_outlined, + iconButtonType: IconButtonType.secondary, + onTap: () { + Navigator.pop(context); + Navigator.pop(context); + }, + ), + ], + ), + SliverList( + delegate: SliverChildBuilderDelegate( + (delegateBuildContext, index) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Column( + children: [ + Column( + children: [ + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: S.of(context).freeUpDeviceSpace, + ), + menuItemColor: colorScheme.fillFaint, + trailingWidget: Icon( + Icons.chevron_right_outlined, + color: colorScheme.strokeBase, + ), + singleBorderRadius: 8, + alignCaptionedTextToLeft: true, + showOnlyLoadingState: true, + onTap: () async { + BackupStatus status; + try { + status = await SyncService.instance + .getBackupStatus(); + } catch (e) { + await showGenericErrorDialog( + context: context, + error: e, + ); + return; + } + + if (status.localIDs.isEmpty) { + // ignore: unawaited_futures + showErrorDialog( + context, + S.of(context).allClear, + S.of(context).noDeviceThatCanBeDeleted, + ); + } else { + final bool? result = await routeToPage( + context, + FreeSpacePage(status), + ); + if (result == true) { + _showSpaceFreedDialog(status); + } + } + }, + ), + MenuSectionDescriptionWidget( + content: S.of(context).freeUpDeviceSpaceDesc, + ), + const SizedBox( + height: 24, + ), + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: S.of(context).removeDuplicates, + ), + menuItemColor: colorScheme.fillFaint, + trailingWidget: Icon( + Icons.chevron_right_outlined, + color: colorScheme.strokeBase, + ), + singleBorderRadius: 8, + alignCaptionedTextToLeft: true, + trailingIconIsMuted: true, + showOnlyLoadingState: true, + onTap: () async { + List duplicates; + try { + duplicates = await DeduplicationService + .instance + .getDuplicateFiles(); + } catch (e) { + await showGenericErrorDialog( + context: context, + error: e, + ); + return; + } + + if (duplicates.isEmpty) { + unawaited( + showErrorDialog( + context, + S.of(context).noDuplicates, + S + .of(context) + .youveNoDuplicateFilesThatCanBeCleared, + ), + ); + } else { + final DeduplicationResult? result = + await routeToPage( + context, + DeduplicatePage(duplicates), + ); + if (result != null) { + _showDuplicateFilesDeletedDialog( + result, + ); + } + } + }, + ), + Align( + alignment: Alignment.centerLeft, + child: MenuSectionDescriptionWidget( + content: S.of(context).removeDuplicatesDesc, + ), + ), + const SizedBox( + height: 24, + ), + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: S.of(context).viewLargeFiles, + ), + menuItemColor: colorScheme.fillFaint, + trailingWidget: Icon( + Icons.chevron_right_outlined, + color: colorScheme.strokeBase, + ), + singleBorderRadius: 8, + alignCaptionedTextToLeft: true, + trailingIconIsMuted: true, + showOnlyLoadingState: true, + onTap: () async { + await routeToPage( + context, + LargeFilesPagePage(), + ); + }, + ), + MenuSectionDescriptionWidget( + content: S.of(context).viewLargeFilesDesc, + ), + ], + ), + ], + ), + ], + ), + ), + ); + }, + childCount: 1, + ), + ), + ], + ), + ); + } + + void _showSpaceFreedDialog(BackupStatus status) { + if (LocalSettings.instance.shouldPromptToRateUs()) { + LocalSettings.instance.setRateUsShownCount( + LocalSettings.instance.getRateUsShownCount() + 1, + ); + showChoiceDialog( + context, + title: S.of(context).success, + body: + S.of(context).youHaveSuccessfullyFreedUp(formatBytes(status.size)), + firstButtonLabel: S.of(context).rateUs, + firstButtonOnTap: () async { + await UpdateService.instance.launchReviewUrl(); + }, + firstButtonType: ButtonType.primary, + secondButtonLabel: S.of(context).ok, + secondButtonOnTap: () async { + if (Platform.isIOS) { + showToast(context, S.of(context).remindToEmptyDeviceTrash); + } + }, + ); + } else { + showDialogWidget( + context: context, + title: S.of(context).success, + body: + S.of(context).youHaveSuccessfullyFreedUp(formatBytes(status.size)), + icon: Icons.download_done_rounded, + isDismissible: true, + buttons: [ + ButtonWidget( + buttonType: ButtonType.neutral, + labelText: S.of(context).ok, + isInAlert: true, + onTap: () async { + if (Platform.isIOS) { + showToast(context, S.of(context).remindToEmptyDeviceTrash); + } + }, + ), + ], + ); + } + } + + void _showDuplicateFilesDeletedDialog(DeduplicationResult result) { + showChoiceDialog( + context, + title: S.of(context).sparkleSuccess, + body: S.of(context).duplicateFileCountWithStorageSaved( + result.count, + formatBytes(result.size), + ), + firstButtonLabel: S.of(context).rateUs, + firstButtonOnTap: () async { + await UpdateService.instance.launchReviewUrl(); + }, + firstButtonType: ButtonType.primary, + secondButtonLabel: S.of(context).ok, + secondButtonOnTap: () async { + showShortToast( + context, + S.of(context).remindToEmptyEnteTrash, + ); + }, + ); + } +} diff --git a/mobile/lib/ui/settings/machine_learning_settings_page.dart b/mobile/lib/ui/settings/machine_learning_settings_page.dart index 38685b4ff5..62ee7a1c0b 100644 --- a/mobile/lib/ui/settings/machine_learning_settings_page.dart +++ b/mobile/lib/ui/settings/machine_learning_settings_page.dart @@ -36,24 +36,29 @@ class MachineLearningSettingsPage extends StatefulWidget { const MachineLearningSettingsPage({super.key}); @override - State createState() => _MachineLearningSettingsPageState(); + State createState() => + _MachineLearningSettingsPageState(); } -class _MachineLearningSettingsPageState extends State { +class _MachineLearningSettingsPageState + extends State { late InitializationState _state; final EnteWakeLock _wakeLock = EnteWakeLock(); - late StreamSubscription _eventSubscription; + late StreamSubscription + _eventSubscription; @override void initState() { super.initState(); - _eventSubscription = Bus.instance.on().listen((event) { + _eventSubscription = + Bus.instance.on().listen((event) { _fetchState(); setState(() {}); }); _fetchState(); _wakeLock.enable(); + MachineLearningController.instance.forceOverrideML(turnOn: true); } void _fetchState() { @@ -65,6 +70,7 @@ class _MachineLearningSettingsPageState extends State LocalSettings.instance.isFaceIndexingEnabled, onChanged: () async { - final isEnabled = await LocalSettings.instance.toggleFaceIndexing(); + final isEnabled = + await LocalSettings.instance.toggleFaceIndexing(); if (isEnabled) { unawaited(FaceMlService.instance.ensureInitialized()); } else { @@ -229,7 +239,9 @@ class _MachineLearningSettingsPageState extends State { final Map _progressMap = {}; @override void initState() { - _progressStream = RemoteAssetsService.instance.progressStream.listen((event) { + _progressStream = + RemoteAssetsService.instance.progressStream.listen((event) { final String url = event.$1; String title = ""; if (url.contains("clip-image")) { @@ -330,17 +343,20 @@ class MagicSearchIndexStatsWidget extends StatefulWidget { }); @override - State createState() => _MagicSearchIndexStatsWidgetState(); + State createState() => + _MagicSearchIndexStatsWidgetState(); } -class _MagicSearchIndexStatsWidgetState extends State { +class _MagicSearchIndexStatsWidgetState + extends State { IndexStatus? _status; late StreamSubscription _eventSubscription; @override void initState() { super.initState(); - _eventSubscription = Bus.instance.on().listen((event) { + _eventSubscription = + Bus.instance.on().listen((event) { _fetchIndexStatus(); }); _fetchIndexStatus(); @@ -416,10 +432,12 @@ class FaceRecognitionStatusWidget extends StatefulWidget { }); @override - State createState() => FaceRecognitionStatusWidgetState(); + State createState() => + FaceRecognitionStatusWidgetState(); } -class FaceRecognitionStatusWidgetState extends State { +class FaceRecognitionStatusWidgetState + extends State { Timer? _timer; @override void initState() { @@ -433,15 +451,22 @@ class FaceRecognitionStatusWidgetState extends State getIndexStatus() async { try { - final indexedFiles = - await FaceMLDataDB.instance.getIndexedFileCount(minimumMlVersion: faceMlVersion); + final indexedFiles = await FaceMLDataDB.instance + .getIndexedFileCount(minimumMlVersion: faceMlVersion); final indexableFiles = (await getIndexableFileIDs()).length; final showIndexedFiles = min(indexedFiles, indexableFiles); final pendingFiles = max(indexableFiles - indexedFiles, 0); - final clusteringDoneRatio = await FaceMLDataDB.instance.getClusteredToIndexableFilesRatio(); - final bool deviceIsHealthy = MachineLearningController.instance.isDeviceHealthy; + final clusteringDoneRatio = + await FaceMLDataDB.instance.getClusteredToIndexableFilesRatio(); + final bool deviceIsHealthy = + MachineLearningController.instance.isDeviceHealthy; - return (showIndexedFiles, pendingFiles, clusteringDoneRatio, deviceIsHealthy); + return ( + showIndexedFiles, + pendingFiles, + clusteringDoneRatio, + deviceIsHealthy + ); } catch (e, s) { _logger.severe('Error getting face recognition status', e, s); rethrow; @@ -471,10 +496,12 @@ class FaceRecognitionStatusWidgetState extends State 0 || clusteringPercentage < 99)) { + if (!isDeviceHealthy && + (pendingFiles > 0 || clusteringPercentage < 99)) { return MenuSectionDescriptionWidget( content: S.of(context).indexingIsPaused, ); @@ -524,7 +551,8 @@ class FaceRecognitionStatusWidgetState extends State { }, ), ), - if (flagService.passKeyEnabled) sectionOptionSpacing, - if (flagService.passKeyEnabled) - MenuItemWidget( - captionedTextWidget: CaptionedTextWidget( - title: context.l10n.passkey, - ), - pressedColor: getEnteColorScheme(context).fillFaint, - trailingIcon: Icons.chevron_right_outlined, - trailingIconIsMuted: true, - onTap: () async => await onPasskeyClick(context), + sectionOptionSpacing, + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: context.l10n.passkey, ), + pressedColor: getEnteColorScheme(context).fillFaint, + trailingIcon: Icons.chevron_right_outlined, + trailingIconIsMuted: true, + onTap: () async => await onPasskeyClick(context), + ), sectionOptionSpacing, MenuItemWidget( captionedTextWidget: CaptionedTextWidget( diff --git a/mobile/lib/ui/settings/storage_card_widget.dart b/mobile/lib/ui/settings/storage_card_widget.dart index 0d8924c836..3554c6fa47 100644 --- a/mobile/lib/ui/settings/storage_card_widget.dart +++ b/mobile/lib/ui/settings/storage_card_widget.dart @@ -272,7 +272,7 @@ class _StorageCardWidgetState extends State { ) : const SizedBox.shrink(), Text( - S.of(context).freeStorageSpace(freeSpace, freeSpaceUnit), + S.of(context).availableStorageSpace(freeSpace, freeSpaceUnit), style: getEnteTextTheme(context) .mini .copyWith(color: textFaintDark), diff --git a/mobile/lib/ui/sharing/show_images_prevew.dart b/mobile/lib/ui/sharing/show_images_prevew.dart new file mode 100644 index 0000000000..eb04e49a6c --- /dev/null +++ b/mobile/lib/ui/sharing/show_images_prevew.dart @@ -0,0 +1,401 @@ +import 'dart:math' as math; +import "dart:ui"; + +import "package:figma_squircle/figma_squircle.dart"; +import "package:flutter/material.dart"; +import "package:photos/core/constants.dart"; +import "package:photos/models/file/file.dart"; +import "package:photos/theme/ente_theme.dart"; +import "package:photos/ui/viewer/file/thumbnail_widget.dart"; + +class LinkPlaceholder extends StatelessWidget { + const LinkPlaceholder({ + required this.files, + super.key, + }); + + final List files; + + @override + Widget build(BuildContext context) { + final int length = files.length; + Widget placeholderWidget = const SizedBox( + height: 300, + width: 300, + ); + + if (length == 1) { + placeholderWidget = _BackDrop( + backDropImage: files[0], + children: [ + LayoutBuilder( + builder: (context, constraints) { + final imageHeight = constraints.maxHeight * 0.9; + return Center( + child: _CustomImage( + width: imageHeight, + height: imageHeight, + file: files[0], + zIndex: 0, + ), + ); + }, + ), + ], + ); + } else if (length == 2) { + placeholderWidget = _BackDrop( + backDropImage: files[0], + children: [ + LayoutBuilder( + builder: ((context, constraints) { + final imageHeight = constraints.maxHeight * 0.52; + return Stack( + children: [ + Positioned( + top: 145, + left: 180, + child: _CustomImage( + height: imageHeight, + width: imageHeight, + file: files[1], + zIndex: 10 * math.pi / 180, + ), + ), + Positioned( + top: 45, + left: 3.2, + child: _CustomImage( + height: imageHeight, + width: imageHeight, + file: files[0], + zIndex: -(10 * math.pi / 180), + imageShadow: const [ + BoxShadow( + offset: Offset(0, 0), + blurRadius: 0.84, + color: Color.fromRGBO(0, 0, 0, 0.11), + ), + BoxShadow( + offset: Offset(0.84, 0.84), + blurRadius: 1.68, + color: Color.fromRGBO(0, 0, 0, 0.09), + ), + BoxShadow( + offset: Offset(2.53, 2.53), + blurRadius: 2.53, + color: Color.fromRGBO(0, 0, 0, 0.05), + ), + BoxShadow( + offset: Offset(5.05, 4.21), + blurRadius: 2.53, + color: Color.fromRGBO(0, 0, 0, 0.02), + ), + BoxShadow( + offset: Offset(7.58, 6.74), + blurRadius: 2.53, + color: Color.fromRGBO(0, 0, 0, 0.0), + ), + ], + ), + ), + ], + ); + }), + ), + ], + ); + } else if (length == 3) { + placeholderWidget = _BackDrop( + backDropImage: files[0], + children: [ + LayoutBuilder( + builder: (context, constraint) { + final imageHeightSmall = constraint.maxHeight * 0.43; + final imageHeightLarge = constraint.maxHeight * 0.50; + return Stack( + children: [ + Positioned( + top: 55, + child: _CustomImage( + height: imageHeightSmall, + width: imageHeightSmall, + file: files[1], + zIndex: -(20 * math.pi / 180), + ), + ), + Positioned( + bottom: 50, + right: -10, + child: _CustomImage( + height: imageHeightSmall, + width: imageHeightSmall, + file: files[2], + zIndex: 20 * math.pi / 180, + ), + ), + Center( + child: _CustomImage( + height: imageHeightLarge, + width: imageHeightLarge, + file: files[0], + zIndex: 0.0, + imageShadow: const [ + BoxShadow( + offset: Offset(0, 1.02), + blurRadius: 2.04, + color: Color.fromRGBO(0, 0, 0, 0.23), + ), + BoxShadow( + offset: Offset(0, 3.06), + blurRadius: 3.06, + color: Color.fromRGBO(0, 0, 0, 0.2), + ), + BoxShadow( + offset: Offset(0, 6.12), + blurRadius: 4.08, + color: Color.fromRGBO(0, 0, 0, 0.12), + ), + BoxShadow( + offset: Offset(0, 11.22), + blurRadius: 5.1, + color: Color.fromRGBO(0, 0, 0, 0.04), + ), + BoxShadow( + offset: Offset(0, 18.36), + blurRadius: 5.1, + color: Color.fromRGBO(0, 0, 0, 0.0), + ), + ], + ), + ), + ], + ); + }, + ), + ], + ); + } else if (length > 3) { + placeholderWidget = _BackDrop( + backDropImage: files[0], + children: [ + LayoutBuilder( + builder: (context, constraint) { + final imageHeightSmall = constraint.maxHeight * 0.43; + final imageHeightLarge = constraint.maxHeight * 0.50; + final boxHeight = constraint.maxHeight * 0.15; + return Stack( + children: [ + Positioned( + top: 30, + left: 25, + child: _CustomImage( + height: imageHeightSmall, + width: imageHeightSmall, + file: files[1], + zIndex: 0.0, + ), + ), + Positioned( + top: 202, + left: 50, + child: _CustomImage( + height: imageHeightSmall, + width: imageHeightSmall, + file: files[2], + zIndex: 0.0, + ), + ), + Positioned( + top: 75, + right: 25, + child: _CustomImage( + height: imageHeightLarge, + width: imageHeightLarge, + file: files[0], + zIndex: 0.0, + imageShadow: const [ + BoxShadow( + offset: Offset(0, 1.02), + blurRadius: 2.04, + color: Color.fromRGBO(0, 0, 0, 0.23), + ), + BoxShadow( + offset: Offset(0, 3.06), + blurRadius: 3.06, + color: Color.fromRGBO(0, 0, 0, 0.2), + ), + BoxShadow( + offset: Offset(0, 6.12), + blurRadius: 4.08, + color: Color.fromRGBO(0, 0, 0, 0.12), + ), + BoxShadow( + offset: Offset(0, 11.22), + blurRadius: 5.1, + color: Color.fromRGBO(0, 0, 0, 0.04), + ), + BoxShadow( + offset: Offset(0, 18.36), + blurRadius: 5.1, + color: Color.fromRGBO(0, 0, 0, 0.0), + ), + ], + ), + ), + Positioned( + top: 290, + left: 270, + child: Stack( + children: [ + Center( + child: Container( + height: boxHeight + 1, + width: boxHeight + 1, + decoration: ShapeDecoration( + color: const Color.fromRGBO(129, 129, 129, 0.1), + shape: SmoothRectangleBorder( + borderRadius: SmoothBorderRadius( + cornerRadius: 12.5, + cornerSmoothing: 1.0, + ), + ), + ), + ), + ), + Center( + child: ClipSmoothRect( + radius: SmoothBorderRadius( + cornerRadius: 12, + cornerSmoothing: 1.0, + ), + child: Container( + height: boxHeight, + width: boxHeight, + color: const Color.fromRGBO(255, 255, 255, 1), + padding: const EdgeInsets.all(4), + child: Center( + child: FittedBox( + child: Text( + "+" "${length - 3}", + style: getEnteTextTheme(context).h3Bold, + ), + ), + ), + ), + ), + ), + ], + ), + ), + ], + ); + }, + ), + ], + ); + } + + return placeholderWidget; + } +} + +class _BackDrop extends StatelessWidget { + const _BackDrop({ + required this.backDropImage, + required this.children, + }); + + final List children; + final EnteFile backDropImage; + + @override + Widget build(BuildContext context) { + return AspectRatio( + aspectRatio: 1, + child: Stack( + children: [ + ThumbnailWidget( + backDropImage, + shouldShowSyncStatus: false, + shouldShowFavoriteIcon: false, + thumbnailSize: thumbnailLargeSize, + ), + BackdropFilter( + filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), + child: Container( + color: Colors.transparent, + ), + ), + ...children, + ], + ), + ); + } +} + +class _CustomImage extends StatelessWidget { + const _CustomImage({ + required this.width, + required this.height, + required this.file, + required this.zIndex, + this.imageShadow, + }); + final List? imageShadow; + final EnteFile file; + final double zIndex; + final double height; + final double width; + + @override + Widget build(BuildContext context) { + return Container( + transform: Matrix4.rotationZ(zIndex), + height: height, + width: width, + child: Stack( + children: [ + Center( + child: Container( + height: height, + width: width, + decoration: ShapeDecoration( + color: const Color.fromRGBO(129, 129, 129, 0.1), + shadows: imageShadow, + shape: SmoothRectangleBorder( + borderRadius: SmoothBorderRadius( + cornerRadius: 21.0, + cornerSmoothing: 1.0, + ), + ), + ), + ), + ), + Center( + child: SizedBox( + height: height - 2, + width: width - 2, + child: ClipSmoothRect( + radius: SmoothBorderRadius( + cornerRadius: 20.0, + cornerSmoothing: 1, + ), + clipBehavior: Clip.antiAliasWithSaveLayer, + child: Container( + decoration: BoxDecoration(boxShadow: imageShadow), + child: ThumbnailWidget( + file, + shouldShowSyncStatus: false, + shouldShowFavoriteIcon: false, + thumbnailSize: thumbnailLargeSize, + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/ui/tabs/shared/all_quick_links_page.dart b/mobile/lib/ui/tabs/shared/all_quick_links_page.dart new file mode 100644 index 0000000000..3a42ad0b61 --- /dev/null +++ b/mobile/lib/ui/tabs/shared/all_quick_links_page.dart @@ -0,0 +1,69 @@ +import "package:flutter/material.dart"; +import "package:photos/generated/l10n.dart"; +import "package:photos/models/collection/collection.dart"; +import "package:photos/ui/components/title_bar_title_widget.dart"; +import "package:photos/ui/tabs/shared/quick_link_album_item.dart"; + +class AllQuickLinksPage extends StatelessWidget { + final List quickLinks; + final String titleHeroTag; + const AllQuickLinksPage({ + required this.quickLinks, + required this.titleHeroTag, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + toolbarHeight: 48, + leadingWidth: 48, + leading: GestureDetector( + onTap: () { + Navigator.pop(context); + }, + child: const Icon( + Icons.arrow_back_outlined, + ), + ), + ), + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TitleBarTitleWidget( + title: S.of(context).quickLinks, + heroTag: titleHeroTag, + ), + Text(quickLinks.length.toString()), + ], + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 20, + horizontal: 16, + ), + child: ListView.separated( + itemBuilder: (context, index) { + return QuickLinkAlbumItem(c: quickLinks[index]); + }, + separatorBuilder: (context, index) { + return const SizedBox(height: 10); + }, + itemCount: quickLinks.length, + physics: const BouncingScrollPhysics(), + ), + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/ui/tabs/shared/quick_link_album_item.dart b/mobile/lib/ui/tabs/shared/quick_link_album_item.dart index 70ed4d3dc7..9debde5e84 100644 --- a/mobile/lib/ui/tabs/shared/quick_link_album_item.dart +++ b/mobile/lib/ui/tabs/shared/quick_link_album_item.dart @@ -4,9 +4,9 @@ import 'package:photos/models/collection/collection.dart'; import 'package:photos/models/collection/collection_items.dart'; import 'package:photos/models/file/file.dart'; import "package:photos/services/collections_service.dart"; -import "package:photos/theme/colors.dart"; import "package:photos/theme/ente_theme.dart"; import "package:photos/ui/common/loading_widget.dart"; +import "package:photos/ui/components/buttons/icon_button_widget.dart"; import "package:photos/ui/viewer/file/no_thumbnail_widget.dart"; import "package:photos/ui/viewer/file/thumbnail_widget.dart"; import "package:photos/ui/viewer/gallery/collection_page.dart"; @@ -20,96 +20,123 @@ class QuickLinkAlbumItem extends StatelessWidget { @override Widget build(BuildContext context) { + final colorScheme = getEnteColorScheme(context); + final textTheme = getEnteTextTheme(context); return GestureDetector( behavior: HitTestBehavior.opaque, child: Container( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + decoration: BoxDecoration( + border: Border.all(color: colorScheme.strokeFainter), + borderRadius: const BorderRadius.all( + Radius.circular(2), + ), + ), child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(1), - child: SizedBox( - height: 60, - width: 60, - child: FutureBuilder( - future: CollectionsService.instance.getCover(c), - builder: (context, snapshot) { - if (snapshot.hasData) { - final String heroTag = heroTagPrefix + snapshot.data!.tag; - return Hero( - tag: heroTag, - child: ThumbnailWidget( - snapshot.data!, - key: ValueKey(heroTag), - ), - ); - } else { - return const NoThumbnailWidget(); - } - }, - ), - ), - ), - const Padding(padding: EdgeInsets.all(8)), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + flex: 6, + child: Row( children: [ - Text( - c.displayName, - style: getEnteTextTheme(context).body, - ), - Padding( - padding: const EdgeInsets.fromLTRB(0, 4, 0, 0), - child: FutureBuilder( - future: CollectionsService.instance.getFileCount(c), + SizedBox( + width: 60, + height: 60, + child: FutureBuilder( + future: CollectionsService.instance.getCover(c), builder: (context, snapshot) { - if (!snapshot.hasError) { - // final String textCount = NumberFormat().format(snapshot.data); - return Row( - children: [ - (!snapshot.hasData) - ? const Padding( - padding: EdgeInsets.symmetric( - horizontal: 16.0, - ), - child: EnteLoadingWidget(size: 10), - ) - : Padding( - padding: - const EdgeInsets.only(right: 8.0), - child: Text( - S.of(context).itemCount(snapshot.data!), - style: getEnteTextTheme(context) - .smallMuted, - ), - ), - const SizedBox(width: 6), - c.hasLink - ? (c.publicURLs!.first!.isExpired - ? const Icon( - Icons.link_outlined, - color: warning500, - ) - : Icon( - Icons.link_outlined, - color: getEnteColorScheme(context) - .strokeMuted, - )) - : const SizedBox.shrink(), - ], + if (snapshot.hasData) { + final String heroTag = + heroTagPrefix + snapshot.data!.tag; + return Hero( + tag: heroTag, + child: ClipRRect( + borderRadius: const BorderRadius.horizontal( + left: Radius.circular(2), + ), + child: ThumbnailWidget( + snapshot.data!, + key: ValueKey(heroTag), + ), + ), ); - } else if (snapshot.hasError) { - return Text(S.of(context).somethingWentWrong); } else { - return const EnteLoadingWidget(size: 10); + return const NoThumbnailWidget(); } }, ), ), + const SizedBox(width: 12), + Flexible( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + c.displayName, + overflow: TextOverflow.ellipsis, + ), + const SizedBox( + height: 2, + ), + FutureBuilder( + future: CollectionsService.instance.getFileCount(c), + builder: (context, snapshot) { + if (!snapshot.hasError) { + if (!snapshot.hasData) { + return Row( + children: [ + EnteLoadingWidget( + size: 10, + color: colorScheme.strokeMuted, + ), + ], + ); + } + final noOfMemories = snapshot.data; + + return Row( + children: [ + Text( + noOfMemories.toString() + " \u2022 ", + style: textTheme.smallMuted, + ), + c.hasLink + ? (c.publicURLs!.first!.isExpired + ? Icon( + Icons.link_outlined, + color: colorScheme.warning500, + size: 22, + ) + : Icon( + Icons.link_outlined, + color: colorScheme.strokeMuted, + size: 22, + )) + : const SizedBox.shrink(), + ], + ); + } else if (snapshot.hasError) { + return Text(S.of(context).somethingWentWrong); + } else { + return const EnteLoadingWidget(size: 10); + } + }, + ), + ], + ), + ), + ), ], ), ), + const Flexible( + flex: 1, + child: IconButtonWidget( + icon: Icons.chevron_right_outlined, + iconButtonType: IconButtonType.secondary, + ), + ), ], ), ), diff --git a/mobile/lib/ui/tabs/shared_collections_tab.dart b/mobile/lib/ui/tabs/shared_collections_tab.dart index 450d3062ca..a0d78192ac 100644 --- a/mobile/lib/ui/tabs/shared_collections_tab.dart +++ b/mobile/lib/ui/tabs/shared_collections_tab.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import "dart:math"; import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; @@ -17,6 +18,7 @@ import "package:photos/ui/components/buttons/icon_button_widget.dart"; import "package:photos/ui/components/divider_widget.dart"; import "package:photos/ui/components/models/button_type.dart"; import 'package:photos/ui/tabs/section_title.dart'; +import "package:photos/ui/tabs/shared/all_quick_links_page.dart"; import "package:photos/ui/tabs/shared/empty_state.dart"; import "package:photos/ui/tabs/shared/quick_link_album_item.dart"; import "package:photos/utils/debouncer.dart"; @@ -97,7 +99,9 @@ class _SharedCollectionsTabState extends State Widget _getSharedCollectionsGallery(SharedCollections collections) { const maxThumbnailWidth = 160.0; - final bool hasQuickLinks = collections.quickLinks.isNotEmpty; + const maxQuickLinks = 6; + final numberOfQuickLinks = collections.quickLinks.length; + const quickLinkTitleHeroTag = "quick_link_title"; final SectionTitle sharedWithYou = SectionTitle(title: S.of(context).sharedWithYou); final SectionTitle sharedByYou = @@ -216,25 +220,56 @@ class _SharedCollectionsTabState extends State ], ), ), - hasQuickLinks + numberOfQuickLinks > 0 ? Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), + padding: const EdgeInsets.symmetric( + vertical: 8.0, + ), child: Column( children: [ SectionOptions( - SectionTitle(title: S.of(context).quickLinks), + Hero( + tag: quickLinkTitleHeroTag, + child: SectionTitle( + title: S.of(context).quickLinks, + ), + ), + trailingWidget: numberOfQuickLinks > maxQuickLinks + ? IconButtonWidget( + icon: Icons.chevron_right, + iconButtonType: IconButtonType.secondary, + onTap: () { + unawaited( + routeToPage( + context, + AllQuickLinksPage( + titleHeroTag: quickLinkTitleHeroTag, + quickLinks: collections.quickLinks, + ), + ), + ); + }, + ) + : null, ), const SizedBox(height: 2), - ListView.builder( + ListView.separated( shrinkWrap: true, - padding: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.only( + bottom: 12, + left: 12, + right: 12, + ), physics: const NeverScrollableScrollPhysics(), itemBuilder: (context, index) { return QuickLinkAlbumItem( c: collections.quickLinks[index], ); }, - itemCount: collections.quickLinks.length, + separatorBuilder: (context, index) { + return const SizedBox(height: 4); + }, + itemCount: min(numberOfQuickLinks, maxQuickLinks), ), ], ), @@ -248,10 +283,10 @@ class _SharedCollectionsTabState extends State Padding( padding: const EdgeInsets.fromLTRB(24, 0, 24, 12), child: ButtonWidget( - buttonType: - !hasQuickLinks && collections.outgoing.isEmpty - ? ButtonType.trailingIconSecondary - : ButtonType.trailingIconPrimary, + buttonType: numberOfQuickLinks == 0 && + collections.outgoing.isEmpty + ? ButtonType.trailingIconSecondary + : ButtonType.trailingIconPrimary, labelText: S.of(context).inviteYourFriendsToEnte, icon: Icons.ios_share_outlined, onTap: () async { diff --git a/mobile/lib/ui/tools/editor/export_video_result.dart b/mobile/lib/ui/tools/editor/export_video_result.dart new file mode 100644 index 0000000000..5ed512d26e --- /dev/null +++ b/mobile/lib/ui/tools/editor/export_video_result.dart @@ -0,0 +1,199 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:fraction/fraction.dart'; +import 'package:path/path.dart' as path; +import 'package:video_player/video_player.dart'; + +Future _getImageDimension( + File file, { + required Function(Size) onResult, +}) async { + final decodedImage = await decodeImageFromList(file.readAsBytesSync()); + onResult(Size(decodedImage.width.toDouble(), decodedImage.height.toDouble())); +} + +String _fileMBSize(File file) => + ' ${(file.lengthSync() / (1024 * 1024)).toStringAsFixed(1)} MB'; + +class VideoResultPopup extends StatefulWidget { + const VideoResultPopup({super.key, required this.video}); + + final File video; + + @override + State createState() => _VideoResultPopupState(); +} + +class _VideoResultPopupState extends State { + VideoPlayerController? _controller; + FileImage? _fileImage; + Size _fileDimension = Size.zero; + late final bool _isGif = + path.extension(widget.video.path).toLowerCase() == ".gif"; + late String _fileMbSize; + + @override + void initState() { + super.initState(); + if (_isGif) { + _getImageDimension( + widget.video, + onResult: (d) => setState(() => _fileDimension = d), + ); + } else { + _controller = VideoPlayerController.file(widget.video); + _controller?.initialize().then((_) { + _fileDimension = _controller?.value.size ?? Size.zero; + setState(() {}); + _controller?.play(); + _controller?.setLooping(true); + }); + } + _fileMbSize = _fileMBSize(widget.video); + } + + @override + void dispose() { + if (_isGif) { + _fileImage?.evict(); + } else { + _controller?.pause(); + _controller?.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(30), + child: Center( + child: Stack( + alignment: Alignment.bottomLeft, + children: [ + AspectRatio( + aspectRatio: _fileDimension.aspectRatio == 0 + ? 1 + : _fileDimension.aspectRatio, + child: + _isGif ? Image.file(widget.video) : VideoPlayer(_controller!), + ), + Positioned( + bottom: 0, + child: FileDescription( + description: { + 'Video path': widget.video.path, + if (!_isGif) + 'Video duration': + '${((_controller?.value.duration.inMilliseconds ?? 0) / 1000).toStringAsFixed(2)}s', + 'Video ratio': Fraction.fromDouble(_fileDimension.aspectRatio) + .reduce() + .toString(), + 'Video dimension': _fileDimension.toString(), + 'Video size': _fileMbSize, + }, + ), + ), + ], + ), + ), + ); + } +} + +class CoverResultPopup extends StatefulWidget { + const CoverResultPopup({super.key, required this.cover}); + + final File cover; + + @override + State createState() => _CoverResultPopupState(); +} + +class _CoverResultPopupState extends State { + late final Uint8List _imagebytes = widget.cover.readAsBytesSync(); + Size? _fileDimension; + late String _fileMbSize; + + @override + void initState() { + super.initState(); + _getImageDimension( + widget.cover, + onResult: (d) => setState(() => _fileDimension = d), + ); + _fileMbSize = _fileMBSize(widget.cover); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(30), + child: Center( + child: Stack( + children: [ + Image.memory(_imagebytes), + Positioned( + bottom: 0, + child: FileDescription( + description: { + 'Cover path': widget.cover.path, + 'Cover ratio': + Fraction.fromDouble(_fileDimension?.aspectRatio ?? 0) + .reduce() + .toString(), + 'Cover dimension': _fileDimension.toString(), + 'Cover size': _fileMbSize, + }, + ), + ), + ], + ), + ), + ); + } +} + +class FileDescription extends StatelessWidget { + const FileDescription({super.key, required this.description}); + + final Map description; + + @override + Widget build(BuildContext context) { + return DefaultTextStyle( + style: const TextStyle(fontSize: 11), + child: Container( + width: MediaQuery.of(context).size.width - 60, + padding: const EdgeInsets.all(10), + color: Colors.black.withOpacity(0.5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: description.entries + .map( + (entry) => Text.rich( + TextSpan( + children: [ + TextSpan( + text: '${entry.key}: ', + style: const TextStyle(fontSize: 11), + ), + TextSpan( + text: entry.value, + style: TextStyle( + fontSize: 10, + color: Colors.white.withOpacity(0.8), + ), + ), + ], + ), + ), + ) + .toList(), + ), + ), + ); + } +} diff --git a/mobile/lib/ui/tools/editor/export_video_service.dart b/mobile/lib/ui/tools/editor/export_video_service.dart new file mode 100644 index 0000000000..7fe4ae767e --- /dev/null +++ b/mobile/lib/ui/tools/editor/export_video_service.dart @@ -0,0 +1,49 @@ +import 'dart:developer'; +import 'dart:io'; + +import 'package:ffmpeg_kit_flutter_min/ffmpeg_kit.dart'; +import 'package:ffmpeg_kit_flutter_min/ffmpeg_kit_config.dart'; +import 'package:ffmpeg_kit_flutter_min/ffmpeg_session.dart'; +import 'package:ffmpeg_kit_flutter_min/return_code.dart'; +import 'package:ffmpeg_kit_flutter_min/statistics.dart'; +import 'package:video_editor/video_editor.dart'; + +class ExportService { + static Future dispose() async { + final executions = await FFmpegKit.listSessions(); + if (executions.isNotEmpty) await FFmpegKit.cancel(); + } + + static Future runFFmpegCommand( + FFmpegVideoEditorExecute execute, { + required void Function(File file) onCompleted, + void Function(Object, StackTrace)? onError, + void Function(Statistics)? onProgress, + }) { + log('FFmpeg start process with command = ${execute.command}'); + return FFmpegKit.executeAsync( + execute.command, + (session) async { + final state = + FFmpegKitConfig.sessionStateToString(await session.getState()); + final code = await session.getReturnCode(); + + if (ReturnCode.isSuccess(code)) { + onCompleted(File(execute.outputPath)); + } else { + if (onError != null) { + onError( + Exception( + 'FFmpeg process exited with state $state and return code $code.\n${await session.getOutput()}', + ), + StackTrace.current, + ); + } + return; + } + }, + null, + onProgress, + ); + } +} diff --git a/mobile/lib/ui/tools/editor/video_crop_page.dart b/mobile/lib/ui/tools/editor/video_crop_page.dart new file mode 100644 index 0000000000..1ef82bd96c --- /dev/null +++ b/mobile/lib/ui/tools/editor/video_crop_page.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; +import "package:photos/ente_theme_data.dart"; +import "package:photos/generated/l10n.dart"; +import "package:photos/ui/tools/editor/video_editor/crop_value.dart"; +import "package:photos/ui/tools/editor/video_editor/video_editor_bottom_action.dart"; +import "package:photos/ui/tools/editor/video_editor/video_editor_main_actions.dart"; +import "package:photos/ui/tools/editor/video_editor/video_editor_navigation_options.dart"; +import "package:photos/ui/tools/editor/video_editor/video_editor_player_control.dart"; +import 'package:video_editor/video_editor.dart'; + +class VideoCropPage extends StatefulWidget { + const VideoCropPage({super.key, required this.controller}); + + final VideoEditorController controller; + + @override + State createState() => _VideoCropPageState(); +} + +class _VideoCropPageState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + elevation: 0, + toolbarHeight: 0, + ), + body: SafeArea( + child: Column( + children: [ + Expanded( + child: Hero( + tag: "video-editor-preview", + child: CropGridViewer.edit( + controller: widget.controller, + rotateCropArea: false, + margin: const EdgeInsets.symmetric(horizontal: 20), + ), + ), + ), + VideoEditorPlayerControl( + controller: widget.controller, + ), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + flex: 4, + child: AnimatedBuilder( + animation: widget.controller, + builder: (_, __) => Column( + children: [ + VideoEditorMainActions( + children: [ + // _buildCropButton(context, CropValue.original), + // const SizedBox(width: 40), + _buildCropButton(context, CropValue.free), + const SizedBox(width: 40), + _buildCropButton(context, CropValue.ratio_1_1), + const SizedBox(width: 40), + _buildCropButton( + context, + CropValue.ratio_9_16, + ), + const SizedBox(width: 40), + _buildCropButton( + context, + CropValue.ratio_16_9, + ), + const SizedBox(width: 40), + _buildCropButton( + context, + CropValue.ratio_3_4, + ), + const SizedBox(width: 40), + _buildCropButton( + context, + CropValue.ratio_4_3, + ), + ], + ), + ], + ), + ), + ), + ], + ), + const SizedBox(height: 40), + VideoEditorNavigationOptions( + color: Theme.of(context).colorScheme.videoPlayerPrimaryColor, + secondaryText: S.of(context).done, + onSecondaryPressed: () { + // WAY 1: validate crop parameters set in the crop view + widget.controller.applyCacheCrop(); + // WAY 2: update manually with Offset values + // controller.updateCrop(const Offset(0.2, 0.2), const Offset(0.8, 0.8)); + Navigator.pop(context); + }, + ), + ], + ), + ), + ); + } + + Widget _buildCropButton(BuildContext context, CropValue value) { + final f = value.getFraction(); + + return VideoEditorBottomAction( + label: value.displayName, + isSelected: value != CropValue.original && + widget.controller.preferredCropAspectRatio == f?.toDouble(), + onPressed: () { + if (value == CropValue.original) { + widget.controller.updateCrop(Offset.zero, const Offset(1.0, 1.0)); + widget.controller.cropAspectRatio(null); + setState(() {}); + } else { + widget.controller.preferredCropAspectRatio = f?.toDouble(); + } + }, + svgPath: "assets/video-editor/video-crop-${value.name}-action.svg", + ); + } +} diff --git a/mobile/lib/ui/tools/editor/video_editor/crop_value.dart b/mobile/lib/ui/tools/editor/video_editor/crop_value.dart new file mode 100644 index 0000000000..96c00736c7 --- /dev/null +++ b/mobile/lib/ui/tools/editor/video_editor/crop_value.dart @@ -0,0 +1,51 @@ +import "package:fraction/fraction.dart"; + +enum CropValue { + original, + free, + ratio_1_1, + ratio_9_16, + ratio_16_9, + ratio_3_4, + ratio_4_3; + + getFraction() { + switch (this) { + case CropValue.original: + return null; + case CropValue.free: + return null; + case CropValue.ratio_1_1: + return 1.toFraction(); + case CropValue.ratio_9_16: + return Fraction.fromString("9/16"); + case CropValue.ratio_16_9: + return Fraction.fromString("16/9"); + case CropValue.ratio_3_4: + return Fraction.fromString("3/4"); + case CropValue.ratio_4_3: + return Fraction.fromString("4/3"); + default: + return null; + } + } + + String get displayName { + switch (this) { + case CropValue.original: + return "Original"; + case CropValue.free: + return "Free"; + case CropValue.ratio_1_1: + return "1:1"; + case CropValue.ratio_9_16: + return "9:16"; + case CropValue.ratio_16_9: + return "16:9"; + case CropValue.ratio_3_4: + return "3:4"; + case CropValue.ratio_4_3: + return "4:3"; + } + } +} diff --git a/mobile/lib/ui/tools/editor/video_editor/video_editor_bottom_action.dart b/mobile/lib/ui/tools/editor/video_editor/video_editor_bottom_action.dart new file mode 100644 index 0000000000..ed21e96dbf --- /dev/null +++ b/mobile/lib/ui/tools/editor/video_editor/video_editor_bottom_action.dart @@ -0,0 +1,70 @@ +import "package:flutter/material.dart"; +import "package:flutter_svg/flutter_svg.dart"; +import "package:photos/ente_theme_data.dart"; + +class VideoEditorBottomAction extends StatelessWidget { + const VideoEditorBottomAction({ + super.key, + required this.label, + this.icon, + this.svgPath, + this.child, + required this.onPressed, + this.isSelected = false, + }) : assert(icon != null || svgPath != null || child != null); + + final String label; + final IconData? icon; + final String? svgPath; + final Widget? child; + final VoidCallback onPressed; + final bool isSelected; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onPressed, + behavior: HitTestBehavior.translucent, + child: Column( + children: [ + Container( + height: 48, + width: 48, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.videoPlayerBackgroundColor, + shape: BoxShape.circle, + border: Border.all( + color: isSelected + ? Theme.of(context).colorScheme.videoPlayerBorderColor + : Colors.transparent, + width: 1, + ), + ), + child: icon != null + ? Icon(icon!) + : svgPath != null + ? SvgPicture.asset( + svgPath!, + colorFilter: ColorFilter.mode( + Theme.of(context).colorScheme.onSurface, + BlendMode.srcIn, + ), + ) + : Padding( + padding: const EdgeInsets.all(2), + child: child!, + ), + ), + const SizedBox(height: 8), + Text( + label, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/ui/tools/editor/video_editor/video_editor_main_actions.dart b/mobile/lib/ui/tools/editor/video_editor/video_editor_main_actions.dart new file mode 100644 index 0000000000..57fc21780d --- /dev/null +++ b/mobile/lib/ui/tools/editor/video_editor/video_editor_main_actions.dart @@ -0,0 +1,25 @@ +import "package:flutter/material.dart"; + +class VideoEditorMainActions extends StatelessWidget { + const VideoEditorMainActions({ + super.key, + required this.children, + }); + + final List children; + + @override + Widget build(BuildContext context) { + return Center( + child: SizedBox( + height: 76, + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 36), + scrollDirection: Axis.horizontal, + shrinkWrap: true, + children: children, + ), + ), + ); + } +} diff --git a/mobile/lib/ui/tools/editor/video_editor/video_editor_navigation_options.dart b/mobile/lib/ui/tools/editor/video_editor/video_editor_navigation_options.dart new file mode 100644 index 0000000000..e9553229a5 --- /dev/null +++ b/mobile/lib/ui/tools/editor/video_editor/video_editor_navigation_options.dart @@ -0,0 +1,50 @@ +import "package:flutter/material.dart"; +import "package:photos/generated/l10n.dart"; + +class VideoEditorNavigationOptions extends StatelessWidget { + const VideoEditorNavigationOptions({ + super.key, + this.primaryText, + this.onPrimaryPressed, + this.color, + required this.secondaryText, + required this.onSecondaryPressed, + }); + + final String? primaryText; + final VoidCallback? onPrimaryPressed; + final String secondaryText; + final VoidCallback? onSecondaryPressed; + final Color? color; + + @override + Widget build(BuildContext context) { + return Hero( + tag: "video-editor-navigation-options", + child: Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + children: [ + const SizedBox(width: 28), + TextButton( + onPressed: onPrimaryPressed?.call ?? Navigator.of(context).pop, + child: Text(primaryText ?? S.of(context).cancel), + ), + const Spacer(), + TextButton( + onPressed: onSecondaryPressed, + style: TextButton.styleFrom( + foregroundColor: color, + ), + child: Text( + secondaryText, + style: TextStyle(color: color), + ), + ), + const SizedBox(width: 28), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/ui/tools/editor/video_editor/video_editor_player_control.dart b/mobile/lib/ui/tools/editor/video_editor/video_editor_player_control.dart new file mode 100644 index 0000000000..719b62dd60 --- /dev/null +++ b/mobile/lib/ui/tools/editor/video_editor/video_editor_player_control.dart @@ -0,0 +1,78 @@ +import "package:flutter/material.dart"; +import "package:photos/ente_theme_data.dart"; +import "package:video_editor/video_editor.dart"; + +class VideoEditorPlayerControl extends StatelessWidget { + const VideoEditorPlayerControl({ + super.key, + required this.controller, + }); + + final VideoEditorController controller; + + @override + Widget build(BuildContext context) { + return Hero( + tag: "video_editor_player_control", + child: AnimatedBuilder( + animation: Listenable.merge([ + controller, + controller.video, + ]), + builder: (_, __) { + final duration = controller.trimmedDuration; + final pos = Duration( + seconds: (controller.videoPosition.inSeconds - + controller.startTrim.inSeconds), + ); + final isPlaying = controller.isPlaying; + + return GestureDetector( + onTap: () { + if (controller.isPlaying) { + controller.video.pause(); + } else { + controller.video.play(); + } + }, + child: Container( + height: 28, + margin: const EdgeInsets.only(top: 24, bottom: 28), + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.videoPlayerBackgroundColor, + borderRadius: BorderRadius.circular(56), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + !isPlaying ? Icons.play_arrow : Icons.pause, + size: 21, + ), + const SizedBox(width: 4), + Text( + "${formatter(pos)} / ${formatter(duration)}", + // ignore: prefer_const_constructors + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ); + }, + ), + ); + } + + String formatter(Duration duration) => [ + duration.inMinutes.remainder(60).toString().padLeft(2, '0'), + duration.inSeconds.remainder(60).toString().padLeft(2, '0'), + ].join(":"); +} diff --git a/mobile/lib/ui/tools/editor/video_editor_page.dart b/mobile/lib/ui/tools/editor/video_editor_page.dart new file mode 100644 index 0000000000..5cb5afac8a --- /dev/null +++ b/mobile/lib/ui/tools/editor/video_editor_page.dart @@ -0,0 +1,297 @@ +import 'dart:io'; +import "dart:math"; + +import 'package:flutter/material.dart'; +import "package:logging/logging.dart"; +import 'package:path/path.dart' as path; +import "package:photo_manager/photo_manager.dart"; +import "package:photos/core/event_bus.dart"; +import "package:photos/db/files_db.dart"; +import "package:photos/ente_theme_data.dart"; +import "package:photos/events/local_photos_updated_event.dart"; +import "package:photos/generated/l10n.dart"; +import "package:photos/models/file/file.dart"; +import "package:photos/models/location/location.dart"; +import "package:photos/services/sync_service.dart"; +import "package:photos/ui/tools/editor/export_video_service.dart"; +import 'package:photos/ui/tools/editor/video_crop_page.dart'; +import "package:photos/ui/tools/editor/video_editor/video_editor_bottom_action.dart"; +import "package:photos/ui/tools/editor/video_editor/video_editor_main_actions.dart"; +import "package:photos/ui/tools/editor/video_editor/video_editor_navigation_options.dart"; +import "package:photos/ui/tools/editor/video_editor/video_editor_player_control.dart"; +import "package:photos/ui/tools/editor/video_rotate_page.dart"; +import "package:photos/ui/tools/editor/video_trim_page.dart"; +import "package:photos/ui/viewer/file/detail_page.dart"; +import "package:photos/utils/dialog_util.dart"; +import "package:photos/utils/navigation_util.dart"; +import "package:photos/utils/toast_util.dart"; +import "package:video_editor/video_editor.dart"; + +class VideoEditorPage extends StatefulWidget { + const VideoEditorPage({ + super.key, + required this.file, + required this.ioFile, + required this.detailPageConfig, + }); + + final EnteFile file; + final File ioFile; + final DetailPageConfiguration detailPageConfig; + + @override + State createState() => _VideoEditorPageState(); +} + +class _VideoEditorPageState extends State { + final _exportingProgress = ValueNotifier(0.0); + final _isExporting = ValueNotifier(false); + final _logger = Logger("VideoEditor"); + + VideoEditorController? _controller; + + @override + void initState() { + super.initState(); + + Future.microtask(() { + _controller = VideoEditorController.file( + widget.ioFile, + minDuration: const Duration(seconds: 1), + cropStyle: CropGridStyle( + background: Theme.of(context).colorScheme.surface, + selectedBoundariesColor: + const ColorScheme.dark().videoPlayerPrimaryColor, + ), + trimStyle: TrimSliderStyle( + onTrimmedColor: const ColorScheme.dark().videoPlayerPrimaryColor, + onTrimmingColor: const ColorScheme.dark().videoPlayerPrimaryColor, + background: Theme.of(context).colorScheme.videoPlayerBackgroundColor, + positionLineColor: + Theme.of(context).colorScheme.videoPlayerBorderColor, + lineColor: Theme.of(context) + .colorScheme + .videoPlayerBorderColor + .withOpacity(0.6), + ), + ); + + _controller!.initialize().then((_) => setState(() {})).catchError( + (error) { + // handle minumum duration bigger than video duration error + Navigator.pop(context); + }, + test: (e) => e is VideoMinDurationError, + ); + }); + } + + @override + void dispose() async { + _exportingProgress.dispose(); + _isExporting.dispose(); + _controller?.dispose().ignore(); + ExportService.dispose().ignore(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: false, + child: Scaffold( + appBar: AppBar( + elevation: 0, + toolbarHeight: 0, + ), + body: _controller != null && _controller!.initialized + ? SafeArea( + child: Stack( + children: [ + Column( + children: [ + Expanded( + child: Column( + children: [ + Expanded( + child: Hero( + tag: "video-editor-preview", + child: CropGridViewer.preview( + controller: _controller!, + ), + ), + ), + VideoEditorPlayerControl( + controller: _controller!, + ), + VideoEditorMainActions( + children: [ + VideoEditorBottomAction( + label: S.of(context).trim, + svgPath: + "assets/video-editor/video-editor-trim-action.svg", + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => VideoTrimPage( + controller: _controller!, + ), + ), + ), + ), + const SizedBox(width: 40), + VideoEditorBottomAction( + label: S.of(context).crop, + svgPath: + "assets/video-editor/video-editor-crop-action.svg", + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => VideoCropPage( + controller: _controller!, + ), + ), + ), + ), + const SizedBox(width: 40), + VideoEditorBottomAction( + label: S.of(context).rotate, + svgPath: + "assets/video-editor/video-editor-rotate-action.svg", + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => VideoRotatePage( + controller: _controller!, + ), + ), + ), + ), + ], + ), + const SizedBox(height: 40), + VideoEditorNavigationOptions( + color: Theme.of(context) + .colorScheme + .videoPlayerPrimaryColor, + secondaryText: S.of(context).saveCopy, + onSecondaryPressed: () { + exportVideo(); + }, + ), + ], + ), + ), + ], + ), + ], + ), + ) + : const Center(child: CircularProgressIndicator()), + ), + ); + } + + void exportVideo() async { + _exportingProgress.value = 0; + _isExporting.value = true; + final dialog = createProgressDialog(context, S.of(context).savingEdits); + await dialog.show(); + + final config = VideoFFmpegVideoEditorConfig( + _controller!, + format: VideoExportFormat.mp4, + // commandBuilder: (config, videoPath, outputPath) { + // final List filters = config.getExportFilters(); + // filters.add('hflip'); // add horizontal flip + + // return '-i $videoPath ${config.filtersCmd(filters)} -preset ultrafast $outputPath'; + // }, + ); + + try { + await ExportService.runFFmpegCommand( + await config.getExecuteConfig(), + onProgress: (stats) { + _exportingProgress.value = + config.getFFmpegProgress(stats.getTime().toInt()); + }, + onError: (e, s) => _logger.severe("Error exporting video", e, s), + onCompleted: (result) async { + _isExporting.value = false; + if (!mounted) return; + + final fileName = path.basenameWithoutExtension(widget.file.title!) + + "_edited_" + + DateTime.now().microsecondsSinceEpoch.toString() + + ".mp4"; + //Disabling notifications for assets changing to insert the file into + //files db before triggering a sync. + await PhotoManager.stopChangeNotify(); + + try { + final AssetEntity? newAsset = + await (PhotoManager.editor.saveVideo(result, title: fileName)); + result.deleteSync(); + final newFile = await EnteFile.fromAsset( + widget.file.deviceFolder ?? '', + newAsset!, + ); + + newFile.creationTime = widget.file.creationTime; + newFile.collectionID = widget.file.collectionID; + newFile.location = widget.file.location; + if (!newFile.hasLocation && widget.file.localID != null) { + final assetEntity = await widget.file.getAsset; + if (assetEntity != null) { + final latLong = await assetEntity.latlngAsync(); + newFile.location = Location( + latitude: latLong.latitude, + longitude: latLong.longitude, + ); + } + } + + newFile.generatedID = + await FilesDB.instance.insertAndGetId(newFile); + Bus.instance + .fire(LocalPhotosUpdatedEvent([newFile], source: "editSave")); + SyncService.instance.sync().ignore(); + showShortToast(context, S.of(context).editsSaved); + _logger.info("Original file " + widget.file.toString()); + _logger.info("Saved edits to file " + newFile.toString()); + final existingFiles = widget.detailPageConfig.files; + final files = (await widget.detailPageConfig.asyncLoader!( + existingFiles[existingFiles.length - 1].creationTime!, + existingFiles[0].creationTime!, + )) + .files; + // the index could be -1 if the files fetched doesn't contain the newly + // edited files + int selectionIndex = files + .indexWhere((file) => file.generatedID == newFile.generatedID); + if (selectionIndex == -1) { + files.add(newFile); + selectionIndex = files.length - 1; + } + await dialog.hide(); + + replacePage( + context, + DetailPage( + widget.detailPageConfig.copyWith( + files: files, + selectedIndex: min(selectionIndex, files.length - 1), + ), + ), + ); + } catch (_) { + await dialog.hide(); + } + }, + ); + } catch (_) { + await dialog.hide(); + } + } +} diff --git a/mobile/lib/ui/tools/editor/video_rotate_page.dart b/mobile/lib/ui/tools/editor/video_rotate_page.dart new file mode 100644 index 0000000000..46d30496d3 --- /dev/null +++ b/mobile/lib/ui/tools/editor/video_rotate_page.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import "package:photos/ente_theme_data.dart"; +import "package:photos/generated/l10n.dart"; +import "package:photos/ui/tools/editor/video_editor/video_editor_bottom_action.dart"; +import "package:photos/ui/tools/editor/video_editor/video_editor_main_actions.dart"; +import "package:photos/ui/tools/editor/video_editor/video_editor_navigation_options.dart"; +import "package:photos/ui/tools/editor/video_editor/video_editor_player_control.dart"; +import 'package:video_editor/video_editor.dart'; + +class VideoRotatePage extends StatelessWidget { + const VideoRotatePage({super.key, required this.controller}); + + final VideoEditorController controller; + + @override + Widget build(BuildContext context) { + final rotation = controller.rotation; + return Scaffold( + appBar: AppBar( + elevation: 0, + toolbarHeight: 0, + ), + body: SafeArea( + child: Column( + children: [ + Expanded( + child: Hero( + tag: "video-editor-preview", + child: CropGridViewer.preview( + controller: controller, + ), + ), + ), + VideoEditorPlayerControl( + controller: controller, + ), + VideoEditorMainActions( + children: [ + VideoEditorBottomAction( + label: S.of(context).left, + onPressed: () => + controller.rotate90Degrees(RotateDirection.left), + icon: Icons.rotate_left, + ), + const SizedBox(width: 40), + VideoEditorBottomAction( + label: S.of(context).right, + onPressed: () => + controller.rotate90Degrees(RotateDirection.right), + icon: Icons.rotate_right, + ), + ], + ), + const SizedBox(height: 40), + VideoEditorNavigationOptions( + color: Theme.of(context).colorScheme.videoPlayerPrimaryColor, + secondaryText: S.of(context).done, + onPrimaryPressed: () { + while (controller.rotation != rotation) { + controller.rotate90Degrees(RotateDirection.left); + } + Navigator.pop(context); + }, + onSecondaryPressed: () { + Navigator.pop(context); + }, + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/ui/tools/editor/video_trim_page.dart b/mobile/lib/ui/tools/editor/video_trim_page.dart new file mode 100644 index 0000000000..fd0c437de9 --- /dev/null +++ b/mobile/lib/ui/tools/editor/video_trim_page.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import "package:photos/ente_theme_data.dart"; +import "package:photos/generated/l10n.dart"; +import "package:photos/ui/tools/editor/video_editor/video_editor_navigation_options.dart"; +import "package:photos/ui/tools/editor/video_editor/video_editor_player_control.dart"; +import 'package:video_editor/video_editor.dart'; + +class VideoTrimPage extends StatefulWidget { + const VideoTrimPage({super.key, required this.controller}); + + final VideoEditorController controller; + + @override + State createState() => _VideoTrimPageState(); +} + +class _VideoTrimPageState extends State { + final double height = 60; + + @override + Widget build(BuildContext context) { + final minTrim = widget.controller.minTrim; + final maxTrim = widget.controller.maxTrim; + + return Scaffold( + appBar: AppBar( + elevation: 0, + toolbarHeight: 0, + ), + body: SafeArea( + child: Column( + children: [ + Expanded( + child: Hero( + tag: "video-editor-preview", + child: CropGridViewer.preview( + controller: widget.controller, + ), + ), + ), + VideoEditorPlayerControl( + controller: widget.controller, + ), + ..._trimSlider(), + const SizedBox(height: 40), + VideoEditorNavigationOptions( + color: Theme.of(context).colorScheme.videoPlayerPrimaryColor, + secondaryText: S.of(context).done, + onPrimaryPressed: () { + // reset trim + widget.controller.updateTrim(minTrim, maxTrim); + Navigator.pop(context); + }, + onSecondaryPressed: () { + // WAY 1: validate crop parameters set in the crop view + widget.controller.applyCacheCrop(); + // WAY 2: update manually with Offset values + // controller.updateCrop(const Offset(0.2, 0.2), const Offset(0.8, 0.8)); + Navigator.pop(context); + }, + ), + ], + ), + ), + ); + } + + List _trimSlider() { + return [ + Container( + width: MediaQuery.of(context).size.width, + margin: EdgeInsets.symmetric(vertical: height / 4, horizontal: 20), + child: TrimSlider( + controller: widget.controller, + height: height, + horizontalMargin: height / 4, + ), + ), + ]; + } + + String formatter(Duration duration) => [ + duration.inMinutes.remainder(60).toString().padLeft(2, '0'), + duration.inSeconds.remainder(60).toString().padLeft(2, '0'), + ].join(":"); +} diff --git a/mobile/lib/ui/viewer/actions/file_selection_actions_widget.dart b/mobile/lib/ui/viewer/actions/file_selection_actions_widget.dart index beeb9164d5..c760d88f3e 100644 --- a/mobile/lib/ui/viewer/actions/file_selection_actions_widget.dart +++ b/mobile/lib/ui/viewer/actions/file_selection_actions_widget.dart @@ -32,7 +32,8 @@ import 'package:photos/ui/components/action_sheet_widget.dart'; import "package:photos/ui/components/bottom_action_bar/selection_action_button_widget.dart"; import 'package:photos/ui/components/buttons/button_widget.dart'; import 'package:photos/ui/components/models/button_type.dart'; -import 'package:photos/ui/sharing/manage_links_widget.dart'; +// import 'package:photos/ui/sharing/manage_links_widget.dart'; +import "package:photos/ui/sharing/show_images_prevew.dart"; import "package:photos/ui/tools/collage/collage_creator_page.dart"; import "package:photos/ui/viewer/location/update_location_data_widget.dart"; import 'package:photos/utils/delete_file_util.dart'; @@ -42,6 +43,7 @@ import 'package:photos/utils/magic_util.dart'; import 'package:photos/utils/navigation_util.dart'; import "package:photos/utils/share_util.dart"; import 'package:photos/utils/toast_util.dart'; +import "package:screenshot/screenshot.dart"; class FileSelectionActionsWidget extends StatefulWidget { final GalleryType type; @@ -73,12 +75,14 @@ class _FileSelectionActionsWidgetState late FilesSplit split; late CollectionActions collectionActions; late bool isCollectionOwner; - + final ScreenshotController screenshotController = ScreenshotController(); + late Uint8List placeholderBytes; // _cachedCollectionForSharedLink is primarily used to avoid creating duplicate // links if user keeps on creating Create link button after selecting // few files. This link is reset on any selection changed; Collection? _cachedCollectionForSharedLink; final GlobalKey shareButtonKey = GlobalKey(); + final GlobalKey sendLinkButtonKey = GlobalKey(); @override void initState() { @@ -157,16 +161,17 @@ class _FileSelectionActionsWidgetState SelectionActionButton( icon: Icons.copy_outlined, labelText: S.of(context).copyLink, - onTap: anyUploadedFiles ? _copyLink : null, + onTap: anyUploadedFiles ? _sendLink : null, ), ); } else { items.add( SelectionActionButton( - icon: Icons.link_outlined, - labelText: S.of(context).shareLink, - onTap: anyUploadedFiles ? _onCreatedSharedLinkClicked : null, + icon: Icons.navigation_rounded, + labelText: S.of(context).sendLink, + onTap: anyUploadedFiles ? _onSendLinkTapped : null, shouldShow: ownedFilesCount > 0, + key: sendLinkButtonKey, ), ); } @@ -409,6 +414,7 @@ class _FileSelectionActionsWidgetState SelectionActionButton( labelText: S.of(context).share, icon: Icons.adaptive.share_outlined, + key: shareButtonKey, onTap: () => shareSelected( context, shareButtonKey, @@ -602,7 +608,23 @@ class _FileSelectionActionsWidgetState } } - Future _onCreatedSharedLinkClicked() async { + Future _createPlaceholder( + List ownedSelectedFiles, + ) async { + final Widget imageWidget = LinkPlaceholder( + files: ownedSelectedFiles, + ); + final double pixelRatio = MediaQuery.devicePixelRatioOf(context); + final bytesOfImageToWidget = await screenshotController.captureFromWidget( + imageWidget, + pixelRatio: pixelRatio, + targetSize: MediaQuery.sizeOf(context), + delay: const Duration(milliseconds: 300), + ); + return bytesOfImageToWidget; + } + + Future _onSendLinkTapped() async { if (split.ownedByCurrentUser.isEmpty) { showShortToast( context, @@ -610,51 +632,19 @@ class _FileSelectionActionsWidgetState ); return; } + final dialog = createProgressDialog( + context, + S.of(context).creatingLink, + isDismissible: true, + ); + await dialog.show(); _cachedCollectionForSharedLink ??= await collectionActions .createSharedCollectionLink(context, split.ownedByCurrentUser); - final actionResult = await showActionSheet( - context: context, - buttons: [ - ButtonWidget( - labelText: S.of(context).copyLink, - buttonType: ButtonType.neutral, - buttonSize: ButtonSize.large, - shouldStickToDarkTheme: true, - buttonAction: ButtonAction.first, - isInAlert: true, - ), - ButtonWidget( - labelText: S.of(context).manageLink, - buttonType: ButtonType.secondary, - buttonSize: ButtonSize.large, - buttonAction: ButtonAction.second, - shouldStickToDarkTheme: true, - isInAlert: true, - ), - ButtonWidget( - labelText: S.of(context).done, - buttonType: ButtonType.secondary, - buttonSize: ButtonSize.large, - buttonAction: ButtonAction.third, - shouldStickToDarkTheme: true, - isInAlert: true, - ), - ], - title: S.of(context).publicLinkCreated, - body: S.of(context).youCanManageYourLinksInTheShareTab, - actionSheetType: ActionSheetType.defaultActionSheet, - ); - if (actionResult?.action != null) { - if (actionResult!.action == ButtonAction.first) { - await _copyLink(); - } - if (actionResult.action == ButtonAction.second) { - await routeToPage( - context, - ManageSharedLinkWidget(collection: _cachedCollectionForSharedLink), - ); - } - } + + final List ownedSelectedFiles = split.ownedByCurrentUser; + placeholderBytes = await _createPlaceholder(ownedSelectedFiles); + await dialog.hide(); + await _sendLink(); widget.selectedFiles.clearAll(); if (mounted) { setState(() => {}); @@ -756,7 +746,7 @@ class _FileSelectionActionsWidgetState } } - Future _copyLink() async { + Future _sendLink() async { if (_cachedCollectionForSharedLink != null) { final String collectionKey = Base58Encode( CollectionsService.instance @@ -764,8 +754,13 @@ class _FileSelectionActionsWidgetState ); final String url = "${_cachedCollectionForSharedLink!.publicURLs?.first?.url}#$collectionKey"; - await Clipboard.setData(ClipboardData(text: url)); - showShortToast(context, S.of(context).linkCopiedToClipboard); + unawaited(Clipboard.setData(ClipboardData(text: url))); + await shareImageAndUrl( + placeholderBytes, + url, + context: context, + key: sendLinkButtonKey, + ); } } diff --git a/mobile/lib/ui/viewer/file/detail_page.dart b/mobile/lib/ui/viewer/file/detail_page.dart index 4368590eae..40af74bb1a 100644 --- a/mobile/lib/ui/viewer/file/detail_page.dart +++ b/mobile/lib/ui/viewer/file/detail_page.dart @@ -13,6 +13,7 @@ import 'package:photos/models/file/file.dart'; import "package:photos/models/file/file_type.dart"; import "package:photos/ui/common/fast_scroll_physics.dart"; import 'package:photos/ui/tools/editor/image_editor_page.dart'; +import "package:photos/ui/tools/editor/video_editor_page.dart"; import "package:photos/ui/viewer/file/file_app_bar.dart"; import "package:photos/ui/viewer/file/file_bottom_bar.dart"; import 'package:photos/ui/viewer/file/file_widget.dart'; @@ -370,6 +371,21 @@ class _DetailPageState extends State { await dialog.hide(); return; } + if (file.fileType == FileType.video) { + await dialog.hide(); + replacePage( + context, + VideoEditorPage( + file: file, + ioFile: ioFile, + detailPageConfig: widget.config.copyWith( + files: _files, + selectedIndex: _selectedIndexNotifier.value, + ), + ), + ); + return; + } final imageProvider = ExtendedFileImageProvider(ioFile, cacheRawData: true); await precacheImage(imageProvider, context); diff --git a/mobile/lib/ui/viewer/file/file_bottom_bar.dart b/mobile/lib/ui/viewer/file/file_bottom_bar.dart index 867a365d54..4d17a25f59 100644 --- a/mobile/lib/ui/viewer/file/file_bottom_bar.dart +++ b/mobile/lib/ui/viewer/file/file_bottom_bar.dart @@ -85,7 +85,8 @@ class FileBottomBarState extends State { if (!widget.showOnlyInfoButton && widget.file is! TrashFile) { if (widget.file.fileType == FileType.image || - widget.file.fileType == FileType.livePhoto) { + widget.file.fileType == FileType.livePhoto || + (widget.file.fileType == FileType.video)) { children.add( Tooltip( message: "Edit", @@ -150,66 +151,59 @@ class FileBottomBarState extends State { return ValueListenableBuilder( valueListenable: widget.enableFullScreenNotifier, builder: (BuildContext context, bool isFullScreen, _) { - return IgnorePointer( - ignoring: isFullScreen, - child: AnimatedOpacity( - opacity: isFullScreen ? 0 : 1, - duration: const Duration(milliseconds: 150), - child: Align( - alignment: Alignment.bottomCenter, - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - Colors.black.withOpacity(0.6), - Colors.black.withOpacity(0.72), - ], - stops: const [0, 0.8, 1], + return Align( + alignment: Alignment.bottomCenter, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withOpacity(0.6), + Colors.black.withOpacity(0.72), + ], + stops: const [0, 0.8, 1], + ), + ), + child: Padding( + padding: EdgeInsets.only(bottom: safeAreaBottomPadding), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + widget.file.caption?.isNotEmpty ?? false + ? Padding( + padding: const EdgeInsets.fromLTRB( + 16, + 12, + 16, + 0, + ), + child: GestureDetector( + onTap: () async { + await _displayDetails(widget.file); + await Future.delayed( + const Duration(milliseconds: 500), + ); //Waiting for some time till the caption gets updated in db if the user closes the bottom sheet without pressing 'done' + safeRefresh(); + }, + child: Text( + widget.file.caption!, + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: getEnteTextTheme(context) + .mini + .copyWith(color: textBaseDark), + textAlign: TextAlign.center, + ), + ), + ) + : const SizedBox.shrink(), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: children, ), - ), - child: Padding( - padding: EdgeInsets.only(bottom: safeAreaBottomPadding), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - widget.file.caption?.isNotEmpty ?? false - ? Padding( - padding: const EdgeInsets.fromLTRB( - 16, - 12, - 16, - 0, - ), - child: GestureDetector( - onTap: () async { - await _displayDetails(widget.file); - await Future.delayed( - const Duration(milliseconds: 500), - ); //Waiting for some time till the caption gets updated in db if the user closes the bottom sheet without pressing 'done' - safeRefresh(); - }, - child: Text( - widget.file.caption!, - overflow: TextOverflow.ellipsis, - maxLines: 1, - style: getEnteTextTheme(context) - .mini - .copyWith(color: textBaseDark), - textAlign: TextAlign.center, - ), - ), - ) - : const SizedBox.shrink(), - Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: children, - ), - ], - ), - ), + ], ), ), ), diff --git a/mobile/lib/ui/viewer/file/file_details_widget.dart b/mobile/lib/ui/viewer/file/file_details_widget.dart index 2423ee77c8..65419c95d6 100644 --- a/mobile/lib/ui/viewer/file/file_details_widget.dart +++ b/mobile/lib/ui/viewer/file/file_details_widget.dart @@ -57,7 +57,7 @@ class _FileDetailsWidgetState extends State { late final StreamSubscription _peopleChangedEvent; - bool _isImage = false; + bool _isImage = false; late int _currentUserID; bool showExifListTile = false; final ValueNotifier hasLocationData = ValueNotifier(false); diff --git a/mobile/lib/ui/viewer/file/file_icons_widget.dart b/mobile/lib/ui/viewer/file/file_icons_widget.dart index 1d22a5c469..3b9d603bb4 100644 --- a/mobile/lib/ui/viewer/file/file_icons_widget.dart +++ b/mobile/lib/ui/viewer/file/file_icons_widget.dart @@ -5,9 +5,12 @@ import 'package:flutter/material.dart'; import 'package:photos/ente_theme_data.dart'; import "package:photos/generated/l10n.dart"; import "package:photos/models/api/collection/user.dart"; +import "package:photos/models/file/file.dart"; import 'package:photos/models/file/trash_file.dart'; import 'package:photos/theme/colors.dart'; +import "package:photos/theme/ente_theme.dart"; import 'package:photos/ui/sharing/user_avator_widget.dart'; +import "package:photos/utils/data_util.dart"; class ThumbnailPlaceHolder extends StatelessWidget { const ThumbnailPlaceHolder({Key? key}) : super(key: key); @@ -121,6 +124,80 @@ class VideoOverlayIcon extends StatelessWidget { } } +class VideoOverlayDuration extends StatelessWidget { + final int? duration; + const VideoOverlayDuration({Key? key, required this.duration}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + late Widget onDarkBackground; + final bool iconFallback = (duration == null || duration == 0); + + double inset = 4; + double size = iconFallback ? 18 : 10; + if (constraints.hasBoundedWidth) { + final w = constraints.maxWidth; + if (w > 120) { + size = iconFallback ? 24 : 14; + } else if (w < 75) { + inset = 3; + size = iconFallback ? 16 : 8; + } + } + + if (iconFallback) { + onDarkBackground = Icon( + Icons.play_arrow, + color: Colors.white, + size: size, //default 24 + ); + } else { + final String formattedDuration = _getFormattedDuration(duration!); + onDarkBackground = Padding( + padding: const EdgeInsets.symmetric(horizontal: 1), + child: Text( + formattedDuration, + style: getEnteTextTheme(context).small.copyWith( + color: Colors.white, + fontSize: size, // Default font size is 14 + ), + ), + ); + } + + return Align( + alignment: Alignment.bottomRight, + child: Padding( + padding: EdgeInsets.only(bottom: inset, right: inset), + child: Container( + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.3), + borderRadius: iconFallback ? null : BorderRadius.circular(8.0), + shape: iconFallback ? BoxShape.circle : BoxShape.rectangle, + ), + padding: const EdgeInsets.symmetric(horizontal: 4), + child: onDarkBackground, + ), + ), + ); + }, + ); + } + + String _getFormattedDuration(int duration) { + final String formattedDuration = + Duration(seconds: duration).toString().split('.').first; + final List separated = formattedDuration.split(':'); + final String hour = (separated[0] == '0') ? '' : separated[0] + ':'; + final String minute = int.parse(separated[1]).toString() + ':'; + final String second = separated[2]; + return hour + minute + second; + } +} + class OwnerAvatarOverlayIcon extends StatelessWidget { final User user; const OwnerAvatarOverlayIcon(this.user, {Key? key}) : super(key: key); @@ -143,15 +220,38 @@ class OwnerAvatarOverlayIcon extends StatelessWidget { class TrashedFileOverlayText extends StatelessWidget { final TrashFile file; - const TrashedFileOverlayText(this.file, {Key? key}) : super(key: key); - @override Widget build(BuildContext context) { final int daysLeft = ((file.deleteBy - DateTime.now().microsecondsSinceEpoch) / Duration.microsecondsPerDay) .ceil(); + final text = S.of(context).trashDaysLeft(daysLeft); + return FileOverlayText(text); + } +} + +class FileSizeOverlayText extends StatelessWidget { + final EnteFile file; + const FileSizeOverlayText(this.file, {Key? key}) : super(key: key); + @override + Widget build(BuildContext context) { + if (file.fileSize == null) { + return const SizedBox.shrink(); + } + final text = convertBytesToReadableFormat(file.fileSize!); + return FileOverlayText(text); + } +} + +class FileOverlayText extends StatelessWidget { + final String text; + + const FileOverlayText(this.text, {Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { return Container( decoration: BoxDecoration( gradient: LinearGradient( @@ -163,7 +263,7 @@ class TrashedFileOverlayText extends StatelessWidget { alignment: Alignment.bottomCenter, padding: const EdgeInsets.only(bottom: 5), child: Text( - S.of(context).trashDaysLeft(daysLeft), + text, style: Theme.of(context) .textTheme .titleSmall! diff --git a/mobile/lib/ui/viewer/file/thumbnail_widget.dart b/mobile/lib/ui/viewer/file/thumbnail_widget.dart index 499981e71b..27a20e95ca 100644 --- a/mobile/lib/ui/viewer/file/thumbnail_widget.dart +++ b/mobile/lib/ui/viewer/file/thumbnail_widget.dart @@ -18,6 +18,8 @@ import 'package:photos/models/file/trash_file.dart'; import 'package:photos/services/collections_service.dart'; import 'package:photos/services/favorites_service.dart'; import 'package:photos/ui/viewer/file/file_icons_widget.dart'; +import "package:photos/ui/viewer/gallery/component/group/type.dart"; +import "package:photos/ui/viewer/gallery/state/gallery_context_state.dart"; import 'package:photos/utils/file_util.dart'; import 'package:photos/utils/thumbnail_util.dart'; @@ -140,7 +142,8 @@ class _ThumbnailWidgetState extends State { } if (widget.file.fileType == FileType.video) { - contentChildren.add(const VideoOverlayIcon()); + contentChildren + .add(VideoOverlayDuration(duration: widget.file.duration!)); } else if (widget.shouldShowLivePhotoOverlay && widget.file.isLiveOrMotionPhoto) { contentChildren.add(const LivePhotoOverlayIcon()); @@ -178,6 +181,8 @@ class _ThumbnailWidgetState extends State { if (widget.file.isTrash) { viewChildren.add(TrashedFileOverlayText(widget.file as TrashFile)); + } else if (GalleryContextState.of(context)?.type == GroupType.size) { + viewChildren.add(FileSizeOverlayText(widget.file)); } // todo: Move this icon overlay to the collection widget. if (widget.shouldShowArchiveStatus) { diff --git a/mobile/lib/ui/viewer/gallery/component/group/group_header_widget.dart b/mobile/lib/ui/viewer/gallery/component/group/group_header_widget.dart index a946cd9d6d..25ea5e7d62 100644 --- a/mobile/lib/ui/viewer/gallery/component/group/group_header_widget.dart +++ b/mobile/lib/ui/viewer/gallery/component/group/group_header_widget.dart @@ -1,16 +1,15 @@ import "package:flutter/cupertino.dart"; -import "package:intl/intl.dart"; import 'package:photos/core/constants.dart'; import "package:photos/generated/l10n.dart"; import "package:photos/theme/ente_theme.dart"; class GroupHeaderWidget extends StatelessWidget { - final int timestamp; + final String title; final int gridSize; const GroupHeaderWidget({ super.key, - required this.timestamp, + required this.title, required this.gridSize, }); @@ -22,7 +21,7 @@ class GroupHeaderWidget extends StatelessWidget { gridSize < photoGridSizeMax ? textTheme.body : textTheme.small; final double horizontalPadding = gridSize < photoGridSizeMax ? 12.0 : 8.0; final double verticalPadding = gridSize < photoGridSizeMax ? 12.0 : 14.0; - final String dayTitle = _getDayTitle(context, timestamp); + return Padding( padding: EdgeInsets.symmetric( horizontal: horizontalPadding, @@ -31,33 +30,12 @@ class GroupHeaderWidget extends StatelessWidget { child: Container( alignment: Alignment.centerLeft, child: Text( - dayTitle, - style: (dayTitle == S.of(context).dayToday) + title, + style: (title == S.of(context).dayToday) ? textStyle : textStyle.copyWith(color: colorScheme.textMuted), ), ), ); } - - String _getDayTitle(BuildContext context, int timestamp) { - final date = DateTime.fromMicrosecondsSinceEpoch(timestamp); - final now = DateTime.now(); - - if (date.year == now.year && date.month == now.month) { - if (date.day == now.day) { - return S.of(context).dayToday; - } else if (date.day == now.day - 1) { - return S.of(context).dayYesterday; - } - } - - if (date.year != DateTime.now().year) { - return DateFormat.yMMMEd(Localizations.localeOf(context).languageCode) - .format(date); - } else { - return DateFormat.MMMEd(Localizations.localeOf(context).languageCode) - .format(date); - } - } } diff --git a/mobile/lib/ui/viewer/gallery/component/group/lazy_group_gallery.dart b/mobile/lib/ui/viewer/gallery/component/group/lazy_group_gallery.dart index 8e81a4eb74..299d84d7d3 100644 --- a/mobile/lib/ui/viewer/gallery/component/group/lazy_group_gallery.dart +++ b/mobile/lib/ui/viewer/gallery/component/group/lazy_group_gallery.dart @@ -11,6 +11,7 @@ import 'package:photos/theme/ente_theme.dart'; import "package:photos/ui/viewer/gallery/component/grid/place_holder_grid_view_widget.dart"; import "package:photos/ui/viewer/gallery/component/group/group_gallery.dart"; import "package:photos/ui/viewer/gallery/component/group/group_header_widget.dart"; +import "package:photos/ui/viewer/gallery/component/group/type.dart"; import 'package:photos/ui/viewer/gallery/gallery.dart'; import "package:photos/ui/viewer/gallery/state/gallery_context_state.dart"; @@ -104,32 +105,30 @@ class _LazyGroupGalleryState extends State { if (_filesInGroup.isEmpty) { return; } - final DateTime groupDate = - DateTime.fromMicrosecondsSinceEpoch(_filesInGroup[0].creationTime!); + final galleryState = context.findAncestorStateOfType(); + final groupType = GalleryContextState.of(context)!.type; + // iterate over files and check if any of the belongs to this group - final anyCandidateForGroup = event.updatedFiles.any((file) { - final fileDate = DateTime.fromMicrosecondsSinceEpoch(file.creationTime!); - return fileDate.year == groupDate.year && - fileDate.month == groupDate.month && - fileDate.day == groupDate.day; - }); + final anyCandidateForGroup = groupType.areModifiedFilesPartOfGroup( + event.updatedFiles, + _filesInGroup[0], + lastFile: _filesInGroup.last, + ); if (anyCandidateForGroup) { + late int startRange, endRange; + (startRange, endRange) = groupType.getGroupRange(_filesInGroup[0]); if (kDebugMode) { _logger.info( - " files were updated due to ${event.reason} on " + - DateTime.fromMicrosecondsSinceEpoch( - groupDate.microsecondsSinceEpoch, - ).toIso8601String(), + " files were updated due to ${event.reason} on type ${groupType.name} from ${DateTime.fromMicrosecondsSinceEpoch(startRange).toIso8601String()}" + " to ${DateTime.fromMicrosecondsSinceEpoch(endRange).toIso8601String()}", ); } if (event.type == EventType.addedOrUpdated || widget.removalEventTypes.contains(event.type)) { // We are reloading the whole group - final dayStartTime = - DateTime(groupDate.year, groupDate.month, groupDate.day); final result = await widget.asyncLoader( - dayStartTime.microsecondsSinceEpoch, - dayStartTime.microsecondsSinceEpoch + microSecondsInDay - 1, + startRange, + endRange, asc: GalleryContextState.of(context)!.sortOrderAsc, ); @@ -144,7 +143,7 @@ class _LazyGroupGalleryState extends State { //[galleryState] will never be null except when LazyLoadingGallery is //used without Gallery as an ancestor. - final galleryState = context.findAncestorStateOfType(); + if (galleryState?.mounted ?? false) { galleryState!.setState(() {}); _filesInGroup = result.files; @@ -178,6 +177,7 @@ class _LazyGroupGalleryState extends State { if (_filesInGroup.isEmpty) { return const SizedBox.shrink(); } + final groupType = GalleryContextState.of(context)!.type; return Column( children: [ Row( @@ -185,7 +185,11 @@ class _LazyGroupGalleryState extends State { children: [ if (widget.enableFileGrouping) GroupHeaderWidget( - timestamp: _filesInGroup[0].creationTime!, + title: groupType.getTitle( + context, + _filesInGroup[0], + lastFile: _filesInGroup.last, + ), gridSize: widget.photoGridSize, ), Expanded(child: Container()), diff --git a/mobile/lib/ui/viewer/gallery/component/group/type.dart b/mobile/lib/ui/viewer/gallery/component/group/type.dart new file mode 100644 index 0000000000..19b1224de9 --- /dev/null +++ b/mobile/lib/ui/viewer/gallery/component/group/type.dart @@ -0,0 +1,198 @@ +import "package:flutter/widgets.dart"; +import "package:intl/intl.dart"; +import "package:photos/core/constants.dart"; +import "package:photos/generated/l10n.dart"; +import "package:photos/models/file/file.dart"; +import "package:photos/utils/date_time_util.dart"; + +enum GroupType { + day, + week, + month, + size, + year, + none, +} + +extension GroupTypeExtension on GroupType { + String get name { + switch (this) { + case GroupType.day: + return "day"; + case GroupType.week: + return "week"; + case GroupType.month: + return "month"; + case GroupType.size: + return "size"; + case GroupType.year: + return "year"; + case GroupType.none: + return "none"; + } + } + + bool timeGrouping() { + return this == GroupType.day || + this == GroupType.week || + this == GroupType.month || + this == GroupType.year; + } + + bool showGroupHeader() { + if (this == GroupType.size || this == GroupType.none) { + return false; + } + return true; + } + + String getTitle(BuildContext context, EnteFile file, {EnteFile? lastFile}) { + if (this == GroupType.day) { + return _getDayTitle(context, file.creationTime!); + } else if (this == GroupType.week) { + // return weeks starting date to end date based on file + final date = DateTime.fromMicrosecondsSinceEpoch(file.creationTime!); + final startOfWeek = date.subtract(Duration(days: date.weekday - 1)); + final endOfWeek = startOfWeek.add(const Duration(days: 6)); + return "${DateFormat.MMMd(Localizations.localeOf(context).languageCode).format(startOfWeek)} - ${DateFormat.MMMd(Localizations.localeOf(context).languageCode).format(endOfWeek)}, ${endOfWeek.year}"; + } else if (this == GroupType.year) { + final date = DateTime.fromMicrosecondsSinceEpoch(file.creationTime!); + return DateFormat.y(Localizations.localeOf(context).languageCode) + .format(date); + } else if (this == GroupType.month) { + final date = DateTime.fromMicrosecondsSinceEpoch(file.creationTime!); + return DateFormat.yMMM(Localizations.localeOf(context).languageCode) + .format(date); + } else { + throw UnimplementedError("getTitle not implemented for $this"); + } + } + + // returns true if the group should be refreshed. + // If groupType is day, it should return true if the list of modified files contains a file that was created on the same day as the first file. + // If groupType is week, it should return true if the list of modified files contains a file that was created in the same week as the first file. + // If groupType is month, it should return true if the list of modified files contains a file that was created in the same month as the first file. + // If groupType is year, it should return true if the list of modified files contains a file that was created in the same year as the first file. + bool areModifiedFilesPartOfGroup( + List modifiedFiles, + EnteFile fistFile, { + EnteFile? lastFile, + }) { + switch (this) { + case GroupType.day: + return modifiedFiles.any( + (file) => areFromSameDay(fistFile.creationTime!, file.creationTime!), + ); + case GroupType.week: + return modifiedFiles.any((file) { + final firstDate = + DateTime.fromMicrosecondsSinceEpoch(fistFile.creationTime!); + final fileDate = + DateTime.fromMicrosecondsSinceEpoch(file.creationTime!); + return areDatesInSameWeek(firstDate, fileDate); + }); + case GroupType.month: + return modifiedFiles.any((file) { + final firstDate = + DateTime.fromMicrosecondsSinceEpoch(fistFile.creationTime!); + final fileDate = + DateTime.fromMicrosecondsSinceEpoch(file.creationTime!); + return firstDate.year == fileDate.year && + firstDate.month == fileDate.month; + }); + case GroupType.year: + return modifiedFiles.any((file) { + final firstDate = + DateTime.fromMicrosecondsSinceEpoch(fistFile.creationTime!); + final fileDate = + DateTime.fromMicrosecondsSinceEpoch(file.creationTime!); + return firstDate.year == fileDate.year; + }); + default: + throw UnimplementedError("not implemented for $this"); + } + } + + // for day, year, month, year type, return the microsecond range of the group + (int, int) getGroupRange(EnteFile file) { + switch (this) { + case GroupType.day: + final date = DateTime.fromMicrosecondsSinceEpoch(file.creationTime!); + final startOfDay = DateTime(date.year, date.month, date.day); + return ( + startOfDay.microsecondsSinceEpoch, + (startOfDay.microsecondsSinceEpoch + microSecondsInDay - 1), + ); + case GroupType.week: + final date = DateTime.fromMicrosecondsSinceEpoch(file.creationTime!); + final startOfWeek = DateTime(date.year, date.month, date.day) + .subtract(Duration(days: date.weekday - 1)); + final endOfWeek = startOfWeek.add(const Duration(days: 7)); + return ( + startOfWeek.microsecondsSinceEpoch, + endOfWeek.microsecondsSinceEpoch - 1 + ); + case GroupType.month: + final date = DateTime.fromMicrosecondsSinceEpoch(file.creationTime!); + final startOfMonth = DateTime(date.year, date.month); + final endOfMonth = DateTime(date.year, date.month + 1); + return ( + startOfMonth.microsecondsSinceEpoch, + endOfMonth.microsecondsSinceEpoch - 1 + ); + case GroupType.year: + final date = DateTime.fromMicrosecondsSinceEpoch(file.creationTime!); + final startOfYear = DateTime(date.year); + final endOfYear = DateTime(date.year + 1); + return ( + startOfYear.microsecondsSinceEpoch, + endOfYear.microsecondsSinceEpoch - 1 + ); + default: + throw UnimplementedError("not implemented for $this"); + } + } + + bool areFromSameGroup(EnteFile first, EnteFile second) { + switch (this) { + case GroupType.day: + return areFromSameDay(first.creationTime!, second.creationTime!); + case GroupType.month: + return DateTime.fromMicrosecondsSinceEpoch(first.creationTime!).year == + DateTime.fromMicrosecondsSinceEpoch(second.creationTime!) + .year && + DateTime.fromMicrosecondsSinceEpoch(first.creationTime!).month == + DateTime.fromMicrosecondsSinceEpoch(second.creationTime!).month; + case GroupType.year: + return DateTime.fromMicrosecondsSinceEpoch(first.creationTime!).year == + DateTime.fromMicrosecondsSinceEpoch(second.creationTime!).year; + case GroupType.week: + final firstDate = + DateTime.fromMicrosecondsSinceEpoch(first.creationTime!); + final secondDate = + DateTime.fromMicrosecondsSinceEpoch(second.creationTime!); + return areDatesInSameWeek(firstDate, secondDate); + default: + throw UnimplementedError("not implemented for $this"); + } + } + + String _getDayTitle(BuildContext context, int timestamp) { + final date = DateTime.fromMicrosecondsSinceEpoch(timestamp); + final now = DateTime.now(); + if (date.year == now.year && date.month == now.month) { + if (date.day == now.day) { + return S.of(context).dayToday; + } else if (date.day == now.day - 1) { + return S.of(context).dayYesterday; + } + } + if (date.year != DateTime.now().year) { + return DateFormat.yMMMEd(Localizations.localeOf(context).languageCode) + .format(date); + } else { + return DateFormat.MMMEd(Localizations.localeOf(context).languageCode) + .format(date); + } + } +} diff --git a/mobile/lib/ui/viewer/gallery/component/multiple_groups_gallery_view.dart b/mobile/lib/ui/viewer/gallery/component/multiple_groups_gallery_view.dart index 094f915d3f..e7ad53f461 100644 --- a/mobile/lib/ui/viewer/gallery/component/multiple_groups_gallery_view.dart +++ b/mobile/lib/ui/viewer/gallery/component/multiple_groups_gallery_view.dart @@ -9,7 +9,10 @@ import "package:photos/models/selected_files.dart"; import "package:photos/ui/common/loading_widget.dart"; import "package:photos/ui/huge_listview/huge_listview.dart"; import 'package:photos/ui/viewer/gallery/component/group/lazy_group_gallery.dart'; +import "package:photos/ui/viewer/gallery/component/group/type.dart"; import "package:photos/ui/viewer/gallery/gallery.dart"; +import "package:photos/ui/viewer/gallery/state/gallery_context_state.dart"; +import "package:photos/utils/data_util.dart"; import "package:photos/utils/local_settings.dart"; import "package:scrollable_positioned_list/scrollable_positioned_list.dart"; @@ -65,6 +68,7 @@ class MultipleGroupsGalleryView extends StatelessWidget { @override Widget build(BuildContext context) { + final gType = GalleryContextState.of(context)!.type; return HugeListView>( controller: itemScroller, startIndex: 0, @@ -123,10 +127,17 @@ class MultipleGroupsGalleryView extends StatelessWidget { }, labelTextBuilder: (int index) { try { + final EnteFile file = groupedFiles[index][0]; + if (gType == GroupType.size) { + return file.fileSize != null + ? convertBytesToReadableFormat(file.fileSize!) + : ""; + } + return DateFormat.yMMM(Localizations.localeOf(context).languageCode) .format( DateTime.fromMicrosecondsSinceEpoch( - groupedFiles[index][0].creationTime!, + file.creationTime!, ), ); } catch (e) { diff --git a/mobile/lib/ui/viewer/gallery/gallery.dart b/mobile/lib/ui/viewer/gallery/gallery.dart index 8213158f93..b255c5c375 100644 --- a/mobile/lib/ui/viewer/gallery/gallery.dart +++ b/mobile/lib/ui/viewer/gallery/gallery.dart @@ -12,10 +12,10 @@ import 'package:photos/models/file/file.dart'; import 'package:photos/models/file_load_result.dart'; import 'package:photos/models/selected_files.dart'; import 'package:photos/ui/common/loading_widget.dart'; +import "package:photos/ui/viewer/gallery/component/group/type.dart"; import "package:photos/ui/viewer/gallery/component/multiple_groups_gallery_view.dart"; import 'package:photos/ui/viewer/gallery/empty_state.dart'; import "package:photos/ui/viewer/gallery/state/gallery_context_state.dart"; -import 'package:photos/utils/date_time_util.dart'; import "package:photos/utils/debouncer.dart"; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; @@ -59,6 +59,7 @@ class Gallery extends StatefulWidget { // add a Function variable to get sort value in bool final SortAscFn? sortAsyncFn; + final GroupType groupType; const Gallery({ required this.asyncLoader, @@ -73,6 +74,7 @@ class Gallery extends StatefulWidget { this.emptyState = const EmptyState(), this.scrollBottomSafeArea = 120.0, this.albumName = '', + this.groupType = GroupType.day, this.enableFileGrouping = true, this.loadingWidget = const EnteLoadingWidget(), this.disableScroll = false, @@ -212,7 +214,9 @@ class GalleryState extends State { // gallery reload bool _onFilesLoaded(List files) { final updatedGroupedFiles = - widget.enableFileGrouping ? _groupFiles(files) : [files]; + widget.enableFileGrouping && widget.groupType.timeGrouping() + ? _groupBasedOnTime(files) + : _genericGroupForPerf(files); if (currentGroupedFiles.length != updatedGroupedFiles.length || currentGroupedFiles.isEmpty) { if (mounted) { @@ -248,6 +252,7 @@ class GalleryState extends State { return GalleryContextState( sortOrderAsc: _sortOrderAsc, inSelectionMode: widget.inSelectionMode, + type: widget.groupType, child: MultipleGroupsGalleryView( itemScroller: _itemScroller, groupedFiles: currentGroupedFiles, @@ -258,28 +263,64 @@ class GalleryState extends State { tagPrefix: widget.tagPrefix, scrollBottomSafeArea: widget.scrollBottomSafeArea, limitSelectionToOne: widget.limitSelectionToOne, - enableFileGrouping: widget.enableFileGrouping, + enableFileGrouping: + widget.enableFileGrouping && widget.groupType.showGroupHeader(), logTag: _logTag, logger: _logger, reloadEvent: widget.reloadEvent, header: widget.header, footer: widget.footer, selectedFiles: widget.selectedFiles, - showSelectAllByDefault: widget.showSelectAllByDefault, + showSelectAllByDefault: + widget.showSelectAllByDefault && widget.groupType.showGroupHeader(), isScrollablePositionedList: widget.isScrollablePositionedList, ), ); } - List> _groupFiles(List files) { + // create groups of 200 files for performance + List> _genericGroupForPerf(List files) { + if (widget.groupType == GroupType.size) { + // sort files by fileSize on the bases of _sortOrderAsc + files.sort((a, b) { + if (_sortOrderAsc) { + return a.fileSize!.compareTo(b.fileSize!); + } else { + return b.fileSize!.compareTo(a.fileSize!); + } + }); + } + // todo:(neeraj) Stick to default group behaviour for magicSearch and editLocationGallery + // In case of Magic search, we need to hide the scrollbar title (can be done + // by specifying none as groupType) + if (widget.groupType != GroupType.size) { + return [files]; + } + + final List> resultGroupedFiles = []; + List singleGroupFile = []; + const int groupSize = 40; + for (int i = 0; i < files.length; i += 1) { + singleGroupFile.add(files[i]); + if (singleGroupFile.length == groupSize) { + resultGroupedFiles.add(singleGroupFile); + singleGroupFile = []; + } + } + if (singleGroupFile.isNotEmpty) { + resultGroupedFiles.add(singleGroupFile); + } + _logger.info('Grouped files into ${resultGroupedFiles.length} groups'); + return resultGroupedFiles; + } + + List> _groupBasedOnTime(List files) { List dailyFiles = []; + final List> resultGroupedFiles = []; for (int index = 0; index < files.length; index++) { if (index > 0 && - !areFromSameDay( - files[index - 1].creationTime!, - files[index].creationTime!, - )) { + !widget.groupType.areFromSameGroup(files[index - 1], files[index])) { resultGroupedFiles.add(dailyFiles); dailyFiles = []; } diff --git a/mobile/lib/ui/viewer/gallery/large_files_page.dart b/mobile/lib/ui/viewer/gallery/large_files_page.dart new file mode 100644 index 0000000000..6b71ecba76 --- /dev/null +++ b/mobile/lib/ui/viewer/gallery/large_files_page.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:photos/core/event_bus.dart'; +import 'package:photos/events/collection_meta_event.dart'; +import 'package:photos/events/collection_updated_event.dart'; +import 'package:photos/events/files_updated_event.dart'; +import "package:photos/generated/l10n.dart"; +import "package:photos/models/file/extensions/file_props.dart"; +import "package:photos/models/file/file.dart"; +import 'package:photos/models/file_load_result.dart'; +import 'package:photos/models/gallery_type.dart'; +import 'package:photos/models/selected_files.dart'; +import "package:photos/services/search_service.dart"; +import 'package:photos/ui/viewer/actions/file_selection_overlay_bar.dart'; +import "package:photos/ui/viewer/gallery/component/group/type.dart"; +import 'package:photos/ui/viewer/gallery/gallery.dart'; + +class LargeFilesPagePage extends StatelessWidget { + final String tagPrefix; + final GalleryType appBarType; + final GalleryType overlayType; + final _selectedFiles = SelectedFiles(); + static const int minLargeFileSize = 50 * 1024 * 1024; + + LargeFilesPagePage({ + this.tagPrefix = "Uncategorized_page", + this.appBarType = GalleryType.homepage, + this.overlayType = GalleryType.homepage, + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final gallery = Gallery( + asyncLoader: (creationStartTime, creationEndTime, {limit, asc}) async { + final List allFiles = + await SearchService.instance.getAllFiles(); + final Set alreadyTracked = {}; + + final filesWithSize = []; + for (final file in allFiles) { + if (file.isOwner && + file.isUploaded && + file.fileSize != null && + file.fileSize! > minLargeFileSize) { + if (!alreadyTracked.contains(file.uploadedFileID!)) { + filesWithSize.add(file); + alreadyTracked.add(file.uploadedFileID!); + } + } + } + final FileLoadResult result = FileLoadResult(filesWithSize, false); + return result; + }, + reloadEvent: Bus.instance.on(), + removalEventTypes: const { + EventType.deletedFromRemote, + EventType.deletedFromEverywhere, + EventType.hide, + }, + forceReloadEvents: [ + Bus.instance.on(), + ], + tagPrefix: tagPrefix, + selectedFiles: _selectedFiles, + sortAsyncFn: () => false, + groupType: GroupType.size, + initialFiles: null, + albumName: S.of(context).viewLargeFiles, + ); + return Scaffold( + appBar: PreferredSize( + preferredSize: const Size.fromHeight(50.0), + child: AppBar( + elevation: 0, + centerTitle: false, + title: Text( + S.of(context).viewLargeFiles, + style: Theme.of(context) + .textTheme + .headlineSmall! + .copyWith(fontSize: 16), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ), + body: Stack( + alignment: Alignment.bottomCenter, + children: [ + gallery, + FileSelectionOverlayBar( + overlayType, + _selectedFiles, + ), + ], + ), + ); + } +} diff --git a/mobile/lib/ui/viewer/gallery/state/gallery_context_state.dart b/mobile/lib/ui/viewer/gallery/state/gallery_context_state.dart index ada72b6f3d..4b202c39bd 100644 --- a/mobile/lib/ui/viewer/gallery/state/gallery_context_state.dart +++ b/mobile/lib/ui/viewer/gallery/state/gallery_context_state.dart @@ -1,12 +1,15 @@ import "package:flutter/material.dart"; +import "package:photos/ui/viewer/gallery/component/group/type.dart"; class GalleryContextState extends InheritedWidget { ///Sorting by creation time final bool sortOrderAsc; final bool inSelectionMode; + final GroupType type; const GalleryContextState({ this.inSelectionMode = false, + this.type = GroupType.day, required this.sortOrderAsc, required Widget child, Key? key, @@ -19,6 +22,7 @@ class GalleryContextState extends InheritedWidget { @override bool updateShouldNotify(GalleryContextState oldWidget) { return sortOrderAsc != oldWidget.sortOrderAsc || - inSelectionMode != oldWidget.inSelectionMode; + inSelectionMode != oldWidget.inSelectionMode || + type != oldWidget.type; } } diff --git a/mobile/lib/ui/viewer/people/add_person_action_sheet.dart b/mobile/lib/ui/viewer/people/add_person_action_sheet.dart index 1897e2b7fd..eb5b3e4b32 100644 --- a/mobile/lib/ui/viewer/people/add_person_action_sheet.dart +++ b/mobile/lib/ui/viewer/people/add_person_action_sheet.dart @@ -209,6 +209,10 @@ class _PersonActionSheetState extends State { ) .toList() : persons; + // sort searchResults alphabetically by name + searchResults.sort( + (a, b) => a.$1.data.name.compareTo(b.$1.data.name), + ); final shouldShowAddPerson = widget.showOptionToCreateNewPerson && (_searchQuery.isEmpty || searchResults.isEmpty); diff --git a/mobile/lib/ui/viewer/people/person_cluster_suggestion.dart b/mobile/lib/ui/viewer/people/person_cluster_suggestion.dart index 2a904720bb..78d4fbe118 100644 --- a/mobile/lib/ui/viewer/people/person_cluster_suggestion.dart +++ b/mobile/lib/ui/viewer/people/person_cluster_suggestion.dart @@ -10,14 +10,21 @@ import "package:photos/face/db.dart"; import "package:photos/face/model/person.dart"; import "package:photos/models/file/file.dart"; import 'package:photos/services/machine_learning/face_ml/feedback/cluster_feedback.dart'; +import "package:photos/services/machine_learning/face_ml/person/person_service.dart"; import "package:photos/theme/ente_theme.dart"; import "package:photos/ui/components/buttons/button_widget.dart"; import "package:photos/ui/components/models/button_type.dart"; -// import "package:photos/ui/viewer/people/add_person_action_sheet.dart"; import "package:photos/ui/viewer/people/cluster_page.dart"; import "package:photos/ui/viewer/people/person_clusters_page.dart"; import "package:photos/ui/viewer/search/result/person_face_widget.dart"; +class SuggestionUserFeedback { + final bool accepted; + final ClusterSuggestion suggestion; + + SuggestionUserFeedback(this.accepted, this.suggestion); +} + class PersonReviewClusterSuggestion extends StatefulWidget { final PersonEntity person; @@ -36,6 +43,8 @@ class _PersonClustersState extends State { Key futureBuilderKeySuggestions = UniqueKey(); Key futureBuilderKeyFaceThumbnails = UniqueKey(); bool canGiveFeedback = true; + List pastUserFeedback = []; + List allSuggestions = []; // Declare a variable for the future late Future> futureClusterSuggestions; @@ -61,6 +70,13 @@ class _PersonClustersState extends State { appBar: AppBar( title: const Text('Review suggestions'), actions: [ + if (pastUserFeedback.isNotEmpty) + IconButton( + icon: const Icon(Icons.undo_outlined), + onPressed: () async { + await _undoLastFeedback(); + }, + ), IconButton( icon: const Icon(Icons.history_outlined), onPressed: () { @@ -87,7 +103,7 @@ class _PersonClustersState extends State { ); } - final allSuggestions = snapshot.data!; + allSuggestions = snapshot.data!; final numberOfDifferentSuggestions = allSuggestions.length; final currentSuggestion = allSuggestions[currentSuggestionIndex]; final int clusterID = currentSuggestion.clusterIDToMerge; @@ -112,7 +128,7 @@ class _PersonClustersState extends State { setState(() {}); } }); - return InkWell( + return GestureDetector( onTap: () { final List sortedFiles = List.from(currentSuggestion.filesInCluster); @@ -130,6 +146,7 @@ class _PersonClustersState extends State { ), ); }, + behavior: HitTestBehavior.opaque, child: Container( padding: const EdgeInsets.symmetric( horizontal: 8.0, @@ -166,6 +183,13 @@ class _PersonClustersState extends State { if (!canGiveFeedback) { return; } + // Store the feedback in case the user wants to revert + pastUserFeedback.add( + SuggestionUserFeedback( + yesOrNo, + allSuggestions[currentSuggestionIndex], + ), + ); if (yesOrNo) { canGiveFeedback = false; await FaceMLDataDB.instance.assignClusterToPerson( @@ -240,7 +264,6 @@ class _PersonClustersState extends State { style: getEnteTextTheme(context).smallMuted, ), Text( - // TODO: come up with a better copy for strings below! "${widget.person.data.name}?", style: getEnteTextTheme(context).largeMuted, ), @@ -449,4 +472,35 @@ class _PersonClustersState extends State { } return faceCrops; } + + Future _undoLastFeedback() async { + if (pastUserFeedback.isNotEmpty) { + final SuggestionUserFeedback lastFeedback = pastUserFeedback.removeLast(); + if (lastFeedback.accepted) { + await PersonService.instance.removeClusterToPerson( + personID: widget.person.remoteID, + clusterID: lastFeedback.suggestion.clusterIDToMerge, + ); + } else { + await FaceMLDataDB.instance.removeNotPersonFeedback( + personID: widget.person.remoteID, + clusterID: lastFeedback.suggestion.clusterIDToMerge, + ); + } + + // futureClusterSuggestions = + // pastUserFeedback.map((element) => element.suggestion) + // as Future>; + + fetch = false; + futureClusterSuggestions = futureClusterSuggestions.then((list) { + return list.sublist(currentSuggestionIndex) + ..insert(0, lastFeedback.suggestion); + }); + currentSuggestionIndex = 0; + futureBuilderKeySuggestions = UniqueKey(); + futureBuilderKeyFaceThumbnails = UniqueKey(); + setState(() {}); + } + } } diff --git a/mobile/lib/ui/viewer/search/result/no_result_widget.dart b/mobile/lib/ui/viewer/search/result/no_result_widget.dart index 48ba811df5..dc64a8e322 100644 --- a/mobile/lib/ui/viewer/search/result/no_result_widget.dart +++ b/mobile/lib/ui/viewer/search/result/no_result_widget.dart @@ -21,7 +21,7 @@ class _NoResultWidgetState extends State { super.initState(); searchTypes = SectionType.values.toList(growable: true); // remove face and content sectionType - searchTypes.remove(SectionType.content); + searchTypes.remove(SectionType.magic); } @override diff --git a/mobile/lib/ui/viewer/search/result/search_result_widget.dart b/mobile/lib/ui/viewer/search/result/search_result_widget.dart index fbd77531a8..965e0fcc0e 100644 --- a/mobile/lib/ui/viewer/search/result/search_result_widget.dart +++ b/mobile/lib/ui/viewer/search/result/search_result_widget.dart @@ -13,14 +13,12 @@ class SearchResultWidget extends StatelessWidget { final SearchResult searchResult; final Future? resultCount; final Function? onResultTap; - final Map? params; const SearchResultWidget( this.searchResult, { Key? key, this.resultCount, this.onResultTap, - this.params, }) : super(key: key); @override diff --git a/mobile/lib/ui/viewer/search_tab/magic_section.dart b/mobile/lib/ui/viewer/search_tab/magic_section.dart new file mode 100644 index 0000000000..d088de92e5 --- /dev/null +++ b/mobile/lib/ui/viewer/search_tab/magic_section.dart @@ -0,0 +1,291 @@ +import "dart:async"; +import "dart:math"; + +import "package:figma_squircle/figma_squircle.dart"; +import "package:flutter/material.dart"; +import "package:photos/core/constants.dart"; +import "package:photos/events/event.dart"; +import "package:photos/models/search/generic_search_result.dart"; +import "package:photos/models/search/recent_searches.dart"; +import "package:photos/models/search/search_types.dart"; +import "package:photos/services/machine_learning/semantic_search/frameworks/ml_framework.dart"; +import "package:photos/theme/ente_theme.dart"; +import "package:photos/ui/viewer/file/no_thumbnail_widget.dart"; +import "package:photos/ui/viewer/file/thumbnail_widget.dart"; +import "package:photos/ui/viewer/search/result/search_result_page.dart"; +import "package:photos/ui/viewer/search_tab/section_header.dart"; +import "package:photos/utils/navigation_util.dart"; + +class MagicSection extends StatefulWidget { + final List magicSearchResults; + const MagicSection(this.magicSearchResults, {super.key}); + + @override + State createState() => _MagicSectionState(); +} + +class _MagicSectionState extends State { + late List _magicSearchResults; + final streamSubscriptions = []; + + @override + void initState() { + super.initState(); + _magicSearchResults = widget.magicSearchResults; + + //At times, ml framework is not initialized when the search results are + //requested (widget.momentsSearchResults is empty) and is initialized + //(which fires MLFrameworkInitializationUpdateEvent with + //InitializationState.initialized) before initState of this widget is + //called. We do listen to MLFrameworkInitializationUpdateEvent and reload + //this widget but the event with InitializationState.initialized would have + //already been fired in the above case. + if (_magicSearchResults.isEmpty) { + SectionType.magic + .getData( + context, + limit: kSearchSectionLimit, + ) + .then((value) { + if (mounted) { + setState(() { + _magicSearchResults = value as List; + }); + } + }); + } + + final streamsToListenTo = SectionType.magic.sectionUpdateEvents(); + for (Stream stream in streamsToListenTo) { + streamSubscriptions.add( + stream.listen((event) async { + final mlFrameWorkEvent = + event as MLFrameworkInitializationUpdateEvent; + if (mlFrameWorkEvent.state == InitializationState.initialized) { + _magicSearchResults = (await SectionType.magic.getData( + context, + limit: kSearchSectionLimit, + )) as List; + setState(() {}); + } + }), + ); + } + } + + @override + void dispose() { + for (var subscriptions in streamSubscriptions) { + subscriptions.cancel(); + } + super.dispose(); + } + + @override + void didUpdateWidget(covariant MagicSection oldWidget) { + super.didUpdateWidget(oldWidget); + //widget.magicSearch is empty when doing a hot reload + if (widget.magicSearchResults.isNotEmpty) { + _magicSearchResults = widget.magicSearchResults; + } + } + + @override + Widget build(BuildContext context) { + if (_magicSearchResults.isEmpty) { + // final textTheme = getEnteTextTheme(context); + // return Padding( + // padding: const EdgeInsets.only(left: 12, right: 8), + // child: Row( + // children: [ + // Expanded( + // child: Column( + // crossAxisAlignment: CrossAxisAlignment.start, + // children: [ + // Text( + // SectionType.magic.sectionTitle(context), + // style: textTheme.largeBold, + // ), + // const SizedBox(height: 24), + // Padding( + // padding: const EdgeInsets.only(left: 4), + // child: Text( + // SectionType.magic.getEmptyStateText(context), + // style: textTheme.smallMuted, + // ), + // ), + // ], + // ), + // ), + // const SizedBox(width: 8), + // const SearchSectionEmptyCTAIcon(SectionType.magic), + // ], + // ), + // ); + return const SizedBox.shrink(); + } else { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionHeader( + SectionType.magic, + hasMore: (_magicSearchResults.length >= kSearchSectionLimit - 1), + ), + const SizedBox(height: 2), + SizedBox( + child: SingleChildScrollView( + clipBehavior: Clip.none, + padding: const EdgeInsets.symmetric(horizontal: 4.5), + physics: const BouncingScrollPhysics(), + scrollDirection: Axis.horizontal, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: _magicSearchResults + .map( + (magicSearchResult) => + MagicRecommendation(magicSearchResult), + ) + .toList(), + ), + ), + ), + ], + ), + ); + } + } +} + +class MagicRecommendation extends StatelessWidget { + static const _width = 100.0; + static const _height = 110.0; + static const _borderWidth = 1.0; + static const _cornerRadius = 12.0; + static const _cornerSmoothing = 1.0; + final GenericSearchResult magicSearchResult; + const MagicRecommendation(this.magicSearchResult, {super.key}); + + @override + Widget build(BuildContext context) { + final heroTag = magicSearchResult.heroTag() + + (magicSearchResult.previewThumbnail()?.tag ?? ""); + final enteTextTheme = getEnteTextTheme(context); + return Padding( + padding: EdgeInsets.symmetric(horizontal: max(2.5 - _borderWidth, 0)), + child: GestureDetector( + onTap: () { + RecentSearches().add(magicSearchResult.name()); + if (magicSearchResult.onResultTap != null) { + magicSearchResult.onResultTap!(context); + } else { + routeToPage( + context, + SearchResultPage( + magicSearchResult, + enableGrouping: false, + ), + ); + } + }, + child: SizedBox( + width: _width + _borderWidth * 2, + height: _height + _borderWidth * 2, + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + ClipSmoothRect( + radius: SmoothBorderRadius( + cornerRadius: _cornerRadius + _borderWidth, + cornerSmoothing: _cornerSmoothing, + ), + child: Container( + color: getEnteColorScheme(context).strokeFaint, + width: _width + _borderWidth * 2, + height: _height + _borderWidth * 2, + ), + ), + Container( + decoration: BoxDecoration( + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 6.25, + offset: const Offset(-1.25, 2.5), + ), + ], + ), + child: ClipSmoothRect( + radius: SmoothBorderRadius( + cornerRadius: _cornerRadius, + cornerSmoothing: _cornerSmoothing, + ), + child: Stack( + alignment: Alignment.bottomCenter, + clipBehavior: Clip.none, + children: [ + SizedBox( + width: _width, + height: _height, + child: magicSearchResult.previewThumbnail() != null + ? Hero( + tag: heroTag, + child: ThumbnailWidget( + magicSearchResult.previewThumbnail()!, + shouldShowArchiveStatus: false, + shouldShowSyncStatus: false, + ), + ) + : const NoThumbnailWidget(), + ), + Container( + height: _height, + width: _width, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withOpacity(0), + Colors.black.withOpacity(0), + Colors.black.withOpacity(0.5), + ], + stops: const [ + 0, + 0.1, + 1, + ], + ), + ), + ), + ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 76, + ), + child: Padding( + padding: const EdgeInsets.only( + bottom: 8, + ), + child: Text( + magicSearchResult.name(), + style: enteTextTheme.small.copyWith( + color: Colors.white, + ), + maxLines: 3, + overflow: TextOverflow.fade, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/lib/ui/viewer/search_tab/search_tab.dart b/mobile/lib/ui/viewer/search_tab/search_tab.dart index 46dcfda036..07a13d9e2b 100644 --- a/mobile/lib/ui/viewer/search_tab/search_tab.dart +++ b/mobile/lib/ui/viewer/search_tab/search_tab.dart @@ -18,6 +18,7 @@ import "package:photos/ui/viewer/search_tab/contacts_section.dart"; import "package:photos/ui/viewer/search_tab/descriptions_section.dart"; import "package:photos/ui/viewer/search_tab/file_type_section.dart"; import "package:photos/ui/viewer/search_tab/locations_section.dart"; +import "package:photos/ui/viewer/search_tab/magic_section.dart"; import "package:photos/ui/viewer/search_tab/moments_section.dart"; import "package:photos/ui/viewer/search_tab/people_section.dart"; import "package:photos/utils/local_settings.dart"; @@ -82,7 +83,6 @@ class _AllSearchSectionsState extends State { @override Widget build(BuildContext context) { final searchTypes = SectionType.values.toList(growable: true); - searchTypes.remove(SectionType.content); return Padding( padding: const EdgeInsets.only(top: 8), @@ -153,6 +153,11 @@ class _AllSearchSectionsState extends State { snapshot.data!.elementAt(index) as List, ); + case SectionType.magic: + return MagicSection( + snapshot.data!.elementAt(index) + as List, + ); default: const SizedBox.shrink(); } diff --git a/mobile/lib/utils/date_time_util.dart b/mobile/lib/utils/date_time_util.dart index 9428968593..d60cd5d79f 100644 --- a/mobile/lib/utils/date_time_util.dart +++ b/mobile/lib/utils/date_time_util.dart @@ -28,6 +28,27 @@ bool areFromSameDay(int firstCreationTime, int secondCreationTime) { firstDate.day == secondDate.day; } +bool areDatesInSameWeek(DateTime date1, DateTime date2) { + if (date1.year == date2.year && + date1.month == date2.month && + date1.day == date2.day) { + return true; + } + final int dayOfWeek1 = date1.weekday; + final int dayOfWeek2 = date2.weekday; + // Calculate the start and end dates of the week for both dates + final DateTime startOfWeek1 = date1.subtract(Duration(days: dayOfWeek1 - 1)); + final DateTime endOfWeek1 = startOfWeek1.add(const Duration(days: 6)); + final DateTime startOfWeek2 = date2.subtract(Duration(days: dayOfWeek2 - 1)); + final DateTime endOfWeek2 = startOfWeek2.add(const Duration(days: 6)); + // Check if the two dates fall within the same week range + if ((date1.isAfter(startOfWeek2) && date1.isBefore(endOfWeek2)) || + (date2.isAfter(startOfWeek1) && date2.isBefore(endOfWeek1))) { + return true; + } + return false; +} + // Create link default names: // Same day: "Dec 19, 2022" // Same month: "Dec 19 - 22, 2022" diff --git a/mobile/lib/utils/share_util.dart b/mobile/lib/utils/share_util.dart index ff9f691bd6..ab39fedf11 100644 --- a/mobile/lib/utils/share_util.dart +++ b/mobile/lib/utils/share_util.dart @@ -1,5 +1,6 @@ import 'dart:async'; import "dart:io"; +import "dart:typed_data"; import 'package:flutter/widgets.dart'; import 'package:logging/logging.dart'; @@ -18,19 +19,8 @@ import 'package:share_plus/share_plus.dart'; import "package:uuid/uuid.dart"; final _logger = Logger("ShareUtil"); -// Set of possible image extensions -final _imageExtension = {"jpg", "jpeg", "png", "heic", "heif", "webp", ".gif"}; -final _videoExtension = { - "mp4", - "mov", - "avi", - "mkv", - "webm", - "wmv", - "flv", - "3gp", -}; -// share is used to share media/files from ente to other apps + +/// share is used to share media/files from ente to other apps Future share( BuildContext context, List files, { @@ -62,9 +52,13 @@ Future share( final paths = await Future.wait(pathFutures); await dialog.hide(); paths.removeWhere((element) => element == null); - final List nonNullPaths = paths.map((element) => element!).toList(); - return Share.shareFiles( - nonNullPaths, + final xFiles = []; + for (String? path in paths) { + if (path == null) continue; + xFiles.add(XFile(path)); + } + await Share.shareXFiles( + xFiles, // required for ipad https://github.com/flutter/flutter/issues/47220#issuecomment-608453383 sharePositionOrigin: shareButtonRect(context, shareButtonKey), ); @@ -79,8 +73,10 @@ Future share( } } +/// Returns the rect of button if context and key are not null +/// If key is null, returned rect will be at the center of the screen Rect shareButtonRect(BuildContext context, GlobalKey? shareButtonKey) { - Size size = MediaQuery.of(context).size; + Size size = MediaQuery.sizeOf(context); final RenderObject? renderObject = shareButtonKey?.currentContext?.findRenderObject(); RenderBox? renderBox; @@ -99,8 +95,21 @@ Rect shareButtonRect(BuildContext context, GlobalKey? shareButtonKey) { ); } -Future shareText(String text) async { - return Share.share(text); +Future shareText( + String text, { + BuildContext? context, + GlobalKey? key, +}) async { + try { + final sharePosOrigin = _sharePosOrigin(context, key); + return Share.share( + text, + sharePositionOrigin: sharePosOrigin, + ); + } catch (e, s) { + _logger.severe("failed to share text", e, s); + return ShareResult.unavailable; + } } Future> convertIncomingSharedMediaToFile( @@ -218,3 +227,37 @@ void shareSelected( shareButtonKey: shareButtonKey, ); } + +Future shareImageAndUrl( + Uint8List imageBytes, + String url, { + BuildContext? context, + GlobalKey? key, +}) async { + final sharePosOrigin = _sharePosOrigin(context, key); + await Share.shareXFiles( + [ + XFile.fromData( + imageBytes, + name: 'placeholder_image.png', + mimeType: 'image/png', + ), + ], + text: url, + sharePositionOrigin: sharePosOrigin, + ); +} + +/// required for ipad https://github.com/flutter/flutter/issues/47220#issuecomment-608453383 +/// This returns the position of the share button if context and key are not null +/// and if not, it returns a default position so that the share sheet on iPad has +/// some position to show up. +Rect _sharePosOrigin(BuildContext? context, GlobalKey? key) { + late final Rect rect; + if (context != null) { + rect = shareButtonRect(context, key); + } else { + rect = const Offset(20.0, 20.0) & const Size(10, 10); + } + return rect; +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 3177e3cb47..9fa31b2a95 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -544,6 +544,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + ffmpeg_kit_flutter_min: + dependency: "direct main" + description: + name: ffmpeg_kit_flutter_min + sha256: "123bfbc0e0b9e7cf6d32d8ba8e08b666d66af0f52c07683dd2305fbfc13f494a" + url: "https://pub.dev" + source: hosted + version: "6.0.3" + ffmpeg_kit_flutter_platform_interface: + dependency: transitive + description: + name: ffmpeg_kit_flutter_platform_interface + sha256: addf046ae44e190ad0101b2fde2ad909a3cd08a2a109f6106d2f7048b7abedee + url: "https://pub.dev" + source: hosted + version: "0.2.1" figma_squircle: dependency: "direct main" description: @@ -569,6 +585,38 @@ packages: url: "https://github.com/jesims/file_saver.git" source: git version: "0.2.9" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "045d372bf19b02aeb69cacf8b4009555fb5f6f0b7ad8016e5f46dd1387ddd492" + url: "https://pub.dev" + source: hosted + version: "0.9.2+1" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: f42eacb83b318e183b1ae24eead1373ab1334084404c8c16e0354f9a3e55d385 + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: a3994c26f10378a039faa11de174d7b78eb8f79e4dd0af2a451410c1a5c3f66b + url: "https://pub.dev" + source: hosted + version: "2.6.2" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: d3547240c20cabf205c7c7f01a50ecdbc413755814d6677f3cb366f04abcead0 + url: "https://pub.dev" + source: hosted + version: "0.9.3+1" fixnum: dependency: transitive description: @@ -832,6 +880,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.2" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: "7b4ca6cf3304575fe9c8ec64813c8d02ee41d2afe60bcfe0678bcb5375d596a2" + url: "https://pub.dev" + source: hosted + version: "2.0.10+1" flutter_test: dependency: "direct dev" description: flutter @@ -850,6 +906,14 @@ packages: url: "https://pub.dev" source: hosted version: "8.2.4" + fraction: + dependency: "direct main" + description: + name: fraction + sha256: "09e9504c9177bbd77df56e5d147abfbb3b43360e64bf61510059c14d6a82d524" + url: "https://pub.dev" + source: hosted + version: "5.0.2" freezed: dependency: "direct dev" description: @@ -1007,6 +1071,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "021834d9c0c3de46bf0fe40341fa07168407f694d9b2bb18d532dc1261867f7a" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "4161e1f843d8480d2e9025ee22411778c3c9eb7e40076dcf2da23d8242b7b51c" + url: "https://pub.dev" + source: hosted + version: "0.8.12+3" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "5d6eb13048cd47b60dbf1a5495424dea226c5faf3950e20bf8120a58efb5b5f3" + url: "https://pub.dev" + source: hosted + version: "3.0.4" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: "6703696ad49f5c3c8356d576d7ace84d1faf459afb07accbb0fae780753ff447" + url: "https://pub.dev" + source: hosted + version: "0.8.12" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "4ed1d9bb36f7cd60aa6e6cd479779cc56a4cb4e4de8f49d487b1aaad831300fa" + url: "https://pub.dev" + source: hosted + version: "0.2.1+1" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "3f5ad1e8112a9a6111c46d0b57a7be2286a9a07fc6e1976fdf5be2bd31d4ff62" + url: "https://pub.dev" + source: hosted + version: "0.2.1+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "9ec26d410ff46f483c5519c29c02ef0e02e13a543f882b152d4bfd2f06802f80" + url: "https://pub.dev" + source: hosted + version: "2.10.0" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: "6ad07afc4eb1bc25f3a01084d28520496c4a3bb0cb13685435838167c9dcedeb" + url: "https://pub.dev" + source: hosted + version: "0.2.1+1" integration_test: dependency: "direct dev" description: flutter @@ -1783,6 +1911,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.3" + screenshot: + dependency: "direct main" + description: + name: screenshot + sha256: "63817697a7835e6ce82add4228e15d233b74d42975c143ad8cfe07009fab866b" + url: "https://pub.dev" + source: hosted + version: "3.0.0" scrollable_positioned_list: dependency: "direct main" description: @@ -1811,18 +1947,18 @@ packages: dependency: "direct main" description: name: share_plus - sha256: "3ef39599b00059db0990ca2e30fca0a29d8b37aae924d60063f8e0184cf20900" + sha256: ef3489a969683c4f3d0239010cc8b7a2a46543a8d139e111c06c558875083544 url: "https://pub.dev" source: hosted - version: "7.2.2" + version: "9.0.0" share_plus_platform_interface: dependency: transitive description: name: share_plus_platform_interface - sha256: "251eb156a8b5fa9ce033747d73535bf53911071f8d3b6f4f0b578505ce0d4496" + sha256: "0f9e4418835d1b2c3ae78fdb918251959106cefdbc4dd43526e182f80e82f6d4" url: "https://pub.dev" source: hosted - version: "3.4.0" + version: "4.0.0" shared_preferences: dependency: "direct main" description: @@ -2148,6 +2284,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" + transparent_image: + dependency: transitive + description: + name: transparent_image + sha256: e8991d955a2094e197ca24c645efec2faf4285772a4746126ca12875e54ca02f + url: "https://pub.dev" + source: hosted + version: "2.0.1" tuple: dependency: "direct main" description: @@ -2292,6 +2436,30 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "32c3c684e02f9bc0afb0ae0aa653337a2fe022e8ab064bcd7ffda27a74e288e3" + url: "https://pub.dev" + source: hosted + version: "1.1.11+1" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: c86987475f162fadff579e7320c7ddda04cd2fdeffbe1129227a85d9ac9e03da + url: "https://pub.dev" + source: hosted + version: "1.1.11+1" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "12faff3f73b1741a36ca7e31b292ddeb629af819ca9efe9953b70bd63fc8cd81" + url: "https://pub.dev" + source: hosted + version: "1.1.11+1" vector_math: dependency: transitive description: @@ -2300,6 +2468,15 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + video_editor: + dependency: "direct main" + description: + path: "." + ref: HEAD + resolved-ref: "1eeb18e2b1ce36bd8ca70178c0d4b485e9982257" + url: "https://github.com/prateekmedia/video_editor.git" + source: git + version: "3.0.0" video_player: dependency: "direct main" description: @@ -2510,5 +2687,5 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.3.0 <4.0.0" - flutter: ">=3.20.0-1.2.pre" + dart: ">=3.4.0 <4.0.0" + flutter: ">=3.22.0" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index e703abb0fa..1b6965989b 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -12,7 +12,7 @@ description: ente photos application # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 0.8.129+649 +version: 0.9.0+700 publish_to: none environment: @@ -62,6 +62,7 @@ dependencies: extended_image: ^8.1.1 fade_indexed_stack: ^0.2.2 fast_base58: ^0.2.1 + ffmpeg_kit_flutter_min: ^6.0.3 figma_squircle: ^0.5.3 file_saver: # Use forked version till this PR is merged: https://github.com/incrediblezayed/file_saver/pull/87 @@ -88,7 +89,9 @@ dependencies: flutter_secure_storage: ^8.0.0 flutter_sodium: ^0.2.0 flutter_staggered_grid_view: ^0.6.2 + flutter_svg: ^2.0.10+1 fluttertoast: ^8.0.6 + fraction: ^5.0.2 freezed_annotation: ^2.4.1 google_nav_bar: ^5.0.5 home_widget: ^0.6.0 @@ -96,6 +99,7 @@ dependencies: http: ^1.1.0 image: ^4.0.17 image_editor: ^1.3.0 + image_picker: ^1.1.1 intl: ^0.19.0 json_annotation: ^4.8.0 latlong2: ^0.9.0 @@ -138,10 +142,11 @@ dependencies: provider: ^6.0.0 quiver: ^3.0.1 receive_sharing_intent: ^1.7.0 + screenshot: ^3.0.0 scrollable_positioned_list: ^0.3.5 sentry: ^7.9.0 sentry_flutter: ^7.9.0 - share_plus: 7.2.2 + share_plus: ^9.0.0 shared_preferences: ^2.0.5 simple_cluster: ^0.3.0 sqflite: ^2.3.0 @@ -157,6 +162,9 @@ dependencies: uni_links: ^0.5.1 url_launcher: ^6.0.3 uuid: ^3.0.7 + video_editor: + git: + url: https://github.com/prateekmedia/video_editor.git video_player: git: url: https://github.com/ente-io/packages.git @@ -228,6 +236,7 @@ flutter: assets: - assets/ - assets/models/clip/ + - assets/video-editor/ fonts: - family: Inter fonts: diff --git a/server/Dockerfile b/server/Dockerfile index 25d5bb0ff4..d902deebfd 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.20-alpine3.17 as builder +FROM golang:1.21-alpine3.17 as builder RUN apk add --no-cache gcc musl-dev git build-base pkgconfig libsodium-dev ENV GOOS=linux diff --git a/server/cmd/museum/main.go b/server/cmd/museum/main.go index 8ccb43cc09..5f8534b9d3 100644 --- a/server/cmd/museum/main.go +++ b/server/cmd/museum/main.go @@ -445,6 +445,7 @@ func main() { publicAPI.POST("/users/two-factor/remove", userHandler.RemoveTwoFactor) publicAPI.POST("/users/two-factor/passkeys/begin", userHandler.BeginPasskeyAuthenticationCeremony) publicAPI.POST("/users/two-factor/passkeys/finish", userHandler.FinishPasskeyAuthenticationCeremony) + publicAPI.GET("/users/two-factor/passkeys/get-token", userHandler.GetTokenForPasskeySession) privateAPI.GET("/users/two-factor/recovery-status", userHandler.GetTwoFactorRecoveryStatus) privateAPI.POST("/users/two-factor/passkeys/configure-recovery", userHandler.ConfigurePasskeyRecovery) privateAPI.GET("/users/two-factor/status", userHandler.GetTwoFactorStatus) @@ -487,7 +488,7 @@ func main() { accountsJwtAuthAPI.GET("/passkeys", passkeysHandler.GetPasskeys) accountsJwtAuthAPI.PATCH("/passkeys/:passkeyID", passkeysHandler.RenamePasskey) accountsJwtAuthAPI.DELETE("/passkeys/:passkeyID", passkeysHandler.DeletePasskey) - accountsJwtAuthAPI.GET("/passkeys/registration/begin", passkeysHandler.BeginRegistration) + accountsJwtAuthAPI.POST("/passkeys/registration/begin", passkeysHandler.BeginRegistration) accountsJwtAuthAPI.POST("/passkeys/registration/finish", passkeysHandler.FinishRegistration) collectionHandler := &api.CollectionHandler{ diff --git a/server/configurations/local.yaml b/server/configurations/local.yaml index fe29b92485..fff43906c7 100644 --- a/server/configurations/local.yaml +++ b/server/configurations/local.yaml @@ -233,11 +233,16 @@ stripe: success: ?status=success&session_id={CHECKOUT_SESSION_ID} cancel: ?status=fail&reason=canceled -# Passkey support (WIP) +# Passkey support (optional) +# Use case: MFA webauthn: - rpid: "example.com" + # Our "Relying Party" ID. This scopes the generated credentials. + # See: https://www.w3.org/TR/webauthn-3/#rp-id + rpid: localhost + # Whitelist of origins from where we will accept WebAuthn requests. + # See: https://github.com/go-webauthn/webauthn rporigins: - - "https://example.com:3005" + - "http://localhost:3001" # Roadmap SSO (optional) # diff --git a/server/ente/errors.go b/server/ente/errors.go index 96e7bd4a1e..89fdebb17f 100644 --- a/server/ente/errors.go +++ b/server/ente/errors.go @@ -125,6 +125,12 @@ var ErrFileNotFoundInAlbum = ApiError{ Message: "File is either deleted or moved to different collection", } +var ErrSessionAlreadyClaimed = ApiError{ + Code: "SESSION_ALREADY_CLAIMED", + Message: "Session is already claimed", + HttpStatusCode: http.StatusConflict, +} + var ErrPublicCollectDisabled = ApiError{ Code: PublicCollectDisabled, Message: "User has not enabled public collect for this url", diff --git a/server/go.mod b/server/go.mod index d034a02b50..60a0e7ea65 100644 --- a/server/go.mod +++ b/server/go.mod @@ -1,6 +1,7 @@ module github.com/ente-io/museum -go 1.20 +go 1.21 + require ( firebase.google.com/go v3.13.0+incompatible @@ -19,7 +20,7 @@ require ( github.com/golang-jwt/jwt v3.2.1+incompatible github.com/golang-migrate/migrate/v4 v4.12.2 github.com/google/go-cmp v0.6.0 - github.com/google/uuid v1.4.0 + github.com/google/uuid v1.6.0 github.com/kong/go-srp v0.0.0-20191210190804-cde1efa3c083 github.com/lib/pq v1.8.0 github.com/lithammer/shortuuid/v3 v3.0.4 @@ -30,12 +31,12 @@ require ( github.com/robfig/cron/v3 v3.0.1 github.com/sirupsen/logrus v1.6.0 github.com/spf13/viper v1.8.1 - github.com/stretchr/testify v1.8.4 + github.com/stretchr/testify v1.9.0 github.com/stripe/stripe-go/v72 v72.37.0 github.com/ua-parser/uap-go v0.0.0-20211112212520-00c877edfe0f github.com/ulule/limiter/v3 v3.8.0 github.com/zsais/go-gin-prometheus v0.1.0 - golang.org/x/crypto v0.17.0 + golang.org/x/crypto v0.21.0 golang.org/x/sync v0.1.0 golang.org/x/text v0.14.0 google.golang.org/api v0.114.0 @@ -47,11 +48,11 @@ require ( cloud.google.com/go/longrunning v0.4.1 // indirect github.com/bytedance/sonic v1.9.1 // indirect github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect - github.com/fxamacker/cbor/v2 v2.5.0 // indirect + github.com/fxamacker/cbor/v2 v2.6.0 // indirect github.com/gabriel-vasile/mimetype v1.4.2 // indirect - github.com/go-webauthn/x v0.1.5 // indirect + github.com/go-webauthn/x v0.1.9 // indirect github.com/goccy/go-json v0.10.2 // indirect - github.com/golang-jwt/jwt/v5 v5.2.0 // indirect + github.com/golang-jwt/jwt/v5 v5.2.1 // indirect github.com/google/go-tpm v0.9.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect github.com/klauspost/cpuid/v2 v2.2.4 // indirect @@ -78,7 +79,7 @@ require ( github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-webauthn/webauthn v0.9.4 + github.com/go-webauthn/webauthn v0.10.2 github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/googleapis/gax-go/v2 v2.7.1 // indirect @@ -108,9 +109,9 @@ require ( github.com/subosito/gotenv v1.2.0 // indirect github.com/ugorji/go/codec v1.2.11 // indirect go.opencensus.io v0.24.0 // indirect - golang.org/x/net v0.17.0 // indirect + golang.org/x/net v0.21.0 // indirect golang.org/x/oauth2 v0.7.0 // indirect - golang.org/x/sys v0.15.0 // indirect + golang.org/x/sys v0.18.0 // indirect golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect diff --git a/server/go.sum b/server/go.sum index 46783cfeaa..544b2b032c 100644 --- a/server/go.sum +++ b/server/go.sum @@ -164,8 +164,8 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsouza/fake-gcs-server v1.17.0/go.mod h1:D1rTE4YCyHFNa99oyJJ5HyclvN/0uQR+pM/VdlL83bw= -github.com/fxamacker/cbor/v2 v2.5.0 h1:oHsG0V/Q6E/wqTS2O1Cozzsy69nqCiguo5Q1a1ADivE= -github.com/fxamacker/cbor/v2 v2.5.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo= +github.com/fxamacker/cbor/v2 v2.6.0 h1:sU6J2usfADwWlYDAFhZBQ6TnLFBHxgesMrQfQgk1tWA= +github.com/fxamacker/cbor/v2 v2.6.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= @@ -193,6 +193,7 @@ github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= @@ -207,10 +208,10 @@ github.com/go-redis/redis/v8 v8.4.2/go.mod h1:A1tbYoHSa1fXwN+//ljcCYYJeLmVrwL9hb github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= -github.com/go-webauthn/webauthn v0.9.4 h1:YxvHSqgUyc5AK2pZbqkWWR55qKeDPhP8zLDr6lpIc2g= -github.com/go-webauthn/webauthn v0.9.4/go.mod h1:LqupCtzSef38FcxzaklmOn7AykGKhAhr9xlRbdbgnTw= -github.com/go-webauthn/x v0.1.5 h1:V2TCzDU2TGLd0kSZOXdrqDVV5JB9ILnKxA9S53CSBw0= -github.com/go-webauthn/x v0.1.5/go.mod h1:qbzWwcFcv4rTwtCLOZd+icnr6B7oSsAGZJqlt8cukqY= +github.com/go-webauthn/webauthn v0.10.2 h1:OG7B+DyuTytrEPFmTX503K77fqs3HDK/0Iv+z8UYbq4= +github.com/go-webauthn/webauthn v0.10.2/go.mod h1:Gd1IDsGAybuvK1NkwUTLbGmeksxuRJjVN2PE/xsPxHs= +github.com/go-webauthn/x v0.1.9 h1:v1oeLmoaa+gPOaZqUdDentu6Rl7HkSSsmOT6gxEQHhE= +github.com/go-webauthn/x v0.1.9/go.mod h1:pJNMlIMP1SU7cN8HNlKJpLEnFHCygLCvaLZ8a1xeoQA= github.com/gobuffalo/here v0.6.0/go.mod h1:wAG085dHOYqUpf+Ap+WOdrPTp5IYcDAs/x7PLa8Y5fM= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= @@ -223,8 +224,8 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt v3.2.1+incompatible h1:73Z+4BJcrTC+KczS6WvTPvRGOp1WmfEP4Q1lOd9Z/+c= github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= -github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= -github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-migrate/migrate/v4 v4.12.2 h1:QI43Tlouiwpp2dK5Y767OouX0snJNRP/NubsVaArzDU= github.com/golang-migrate/migrate/v4 v4.12.2/go.mod h1:HQ1DaC8uLHkg4afY8ZQ8D/P5SG+YW9X5INZBVvm+d2k= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= @@ -292,6 +293,7 @@ github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIG github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk= github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw= +github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= @@ -309,8 +311,8 @@ github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm4 github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= -github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/enterprise-certificate-proxy v0.2.3 h1:yk9/cqRKtT9wXZSsRH9aurXEpJX+U6FLtpYTdC3R06k= github.com/googleapis/enterprise-certificate-proxy v0.2.3/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= @@ -577,8 +579,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stripe/stripe-go/v72 v72.37.0 h1:y/PW0SeIk17S1uq6tQ0RdyeizG1anZlvowMZ4AQ17YY= github.com/stripe/stripe-go/v72 v72.37.0/go.mod h1:QwqJQtduHubZht9mek5sds9CtQcKFdsykV9ZepRWwo0= github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= @@ -649,8 +651,8 @@ golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= -golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= -golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -732,8 +734,8 @@ golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLd golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= -golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -828,8 +830,8 @@ golang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210608053332-aa57babbf139/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/server/migrations/87_passkey_login_token.down.sql b/server/migrations/87_passkey_login_token.down.sql new file mode 100644 index 0000000000..1af9da976c --- /dev/null +++ b/server/migrations/87_passkey_login_token.down.sql @@ -0,0 +1,6 @@ +-- Add types for the new dcs that are introduced for the derived data + +ALTER TABLE passkey_login_sessions + DROP COLUMN IF EXISTS token_fetch_cnt, + DROP COLUMN IF EXISTS verified_at, + DROP COLUMN IF EXISTS token_data; diff --git a/server/migrations/87_passkey_login_token.up.sql b/server/migrations/87_passkey_login_token.up.sql new file mode 100644 index 0000000000..0c44050aca --- /dev/null +++ b/server/migrations/87_passkey_login_token.up.sql @@ -0,0 +1,6 @@ +-- Add columns to passkey_login_sessions table for facilitating token fetch in case of passkey redirect +-- not working. +ALTER TABLE passkey_login_sessions + ADD COLUMN IF NOT EXISTS token_fetch_cnt int default 0, + ADD COLUMN IF NOT EXISTS verified_at BIGINT, + ADD COLUMN IF NOT EXISTS token_data jsonb; diff --git a/server/pkg/api/file.go b/server/pkg/api/file.go index 990336e372..064bc3be08 100644 --- a/server/pkg/api/file.go +++ b/server/pkg/api/file.go @@ -139,7 +139,7 @@ func (h *FileHandler) GetMultipartUploadURLs(c *gin.Context) { // Get redirects the request to the file location func (h *FileHandler) Get(c *gin.Context) { userID, fileID := getUserAndFileIDs(c) - url, err := h.Controller.GetFileURL(userID, fileID) + url, err := h.Controller.GetFileURL(c, userID, fileID) if err != nil { handler.Error(c, stacktrace.Propagate(err, "")) return @@ -151,7 +151,7 @@ func (h *FileHandler) Get(c *gin.Context) { // GetV2 returns the URL of the file to client func (h *FileHandler) GetV2(c *gin.Context) { userID, fileID := getUserAndFileIDs(c) - url, err := h.Controller.GetFileURL(userID, fileID) + url, err := h.Controller.GetFileURL(c, userID, fileID) if err != nil { handler.Error(c, stacktrace.Propagate(err, "")) return @@ -164,7 +164,7 @@ func (h *FileHandler) GetV2(c *gin.Context) { // GetThumbnail redirects the request to the file's thumbnail location func (h *FileHandler) GetThumbnail(c *gin.Context) { userID, fileID := getUserAndFileIDs(c) - url, err := h.Controller.GetThumbnailURL(userID, fileID) + url, err := h.Controller.GetThumbnailURL(c, userID, fileID) if err != nil { handler.Error(c, stacktrace.Propagate(err, "")) return @@ -176,7 +176,7 @@ func (h *FileHandler) GetThumbnail(c *gin.Context) { // GetThumbnailV2 returns the URL of the thumbnail to the client func (h *FileHandler) GetThumbnailV2(c *gin.Context) { userID, fileID := getUserAndFileIDs(c) - url, err := h.Controller.GetThumbnailURL(userID, fileID) + url, err := h.Controller.GetThumbnailURL(c, userID, fileID) if err != nil { handler.Error(c, stacktrace.Propagate(err, "")) return diff --git a/server/pkg/api/user.go b/server/pkg/api/user.go index eca3804e5c..71e050fdeb 100644 --- a/server/pkg/api/user.go +++ b/server/pkg/api/user.go @@ -325,6 +325,17 @@ func (h *UserHandler) BeginPasskeyAuthenticationCeremony(c *gin.Context) { return } + isSessionAlreadyClaimed, err := h.UserController.PasskeyRepo.IsSessionAlreadyClaimed(request.SessionID) + if err != nil { + handler.Error(c, stacktrace.Propagate(err, "")) + return + } + + if isSessionAlreadyClaimed { + handler.Error(c, stacktrace.Propagate(&ente.ErrSessionAlreadyClaimed, "Session already claimed")) + return + } + user, err := h.UserController.UserRepo.Get(userID) if err != nil { handler.Error(c, stacktrace.Propagate(err, "")) @@ -374,6 +385,26 @@ func (h *UserHandler) FinishPasskeyAuthenticationCeremony(c *gin.Context) { return } + err = h.UserController.PasskeyRepo.StoreTokenData(request.SessionID, response) + if err != nil { + handler.Error(c, stacktrace.Propagate(err, "failed to store token data")) + return + } + + c.JSON(http.StatusOK, response) +} + +func (h *UserHandler) GetTokenForPasskeySession(c *gin.Context) { + sessionID := c.Query("sessionID") + if sessionID == "" { + handler.Error(c, stacktrace.Propagate(ente.NewBadRequestWithMessage("sessionID is required"), "")) + return + } + response, err := h.UserController.PasskeyRepo.GetTokenData(sessionID) + if err != nil { + handler.Error(c, stacktrace.Propagate(err, "failed to get token data")) + return + } c.JSON(http.StatusOK, response) } diff --git a/server/pkg/controller/file.go b/server/pkg/controller/file.go index b3fec115d0..b5bad99c33 100644 --- a/server/pkg/controller/file.go +++ b/server/pkg/controller/file.go @@ -285,12 +285,12 @@ func (c *FileController) GetUploadURLs(ctx context.Context, userID int64, count } // GetFileURL verifies permissions and returns a presigned url to the requested file -func (c *FileController) GetFileURL(userID int64, fileID int64) (string, error) { +func (c *FileController) GetFileURL(ctx *gin.Context, userID int64, fileID int64) (string, error) { err := c.verifyFileAccess(userID, fileID) if err != nil { return "", stacktrace.Propagate(err, "") } - url, err := c.getSignedURLForType(fileID, ente.FILE) + url, err := c.getSignedURLForType(ctx, fileID, ente.FILE) if err != nil { if errors.Is(err, sql.ErrNoRows) { go c.CleanUpStaleCollectionFiles(userID, fileID) @@ -301,12 +301,12 @@ func (c *FileController) GetFileURL(userID int64, fileID int64) (string, error) } // GetThumbnailURL verifies permissions and returns a presigned url to the requested thumbnail -func (c *FileController) GetThumbnailURL(userID int64, fileID int64) (string, error) { +func (c *FileController) GetThumbnailURL(ctx *gin.Context, userID int64, fileID int64) (string, error) { err := c.verifyFileAccess(userID, fileID) if err != nil { return "", stacktrace.Propagate(err, "") } - url, err := c.getSignedURLForType(fileID, ente.THUMBNAIL) + url, err := c.getSignedURLForType(ctx, fileID, ente.THUMBNAIL) if err != nil { if errors.Is(err, sql.ErrNoRows) { go c.CleanUpStaleCollectionFiles(userID, fileID) @@ -356,7 +356,7 @@ func (c *FileController) GetPublicFileURL(ctx *gin.Context, fileID int64, objTyp if !accessible { return "", stacktrace.Propagate(ente.ErrPermissionDenied, "") } - return c.getSignedURLForType(fileID, objType) + return c.getSignedURLForType(ctx, fileID, objType) } // GetCastFileUrl verifies permissions and returns a presigned url to the requested file @@ -369,15 +369,43 @@ func (c *FileController) GetCastFileUrl(ctx *gin.Context, fileID int64, objType if !accessible { return "", stacktrace.Propagate(ente.ErrPermissionDenied, "") } - return c.getSignedURLForType(fileID, objType) + return c.getSignedURLForType(ctx, fileID, objType) } -func (c *FileController) getSignedURLForType(fileID int64, objType ente.ObjectType) (string, error) { +func (c *FileController) getSignedURLForType(ctx *gin.Context, fileID int64, objType ente.ObjectType) (string, error) { + if isCliRequest(ctx) { + return c.getWasabiSignedUrlIfAvailable(fileID, objType) + } s3Object, err := c.ObjectRepo.GetObject(fileID, objType) if err != nil { return "", stacktrace.Propagate(err, "") } - return c.getPreSignedURL(s3Object.ObjectKey) + return c.getHotDcSignedUrl(s3Object.ObjectKey) +} + +func isCliRequest(ctx *gin.Context) bool { + // check if user-agent contains go-resty + userAgent := ctx.Request.Header.Get("User-Agent") + return strings.Contains(userAgent, "go-resty") +} + +// getWasabiSignedUrlIfAvailable returns a signed URL for the given fileID and objectType. It prefers wasabi over b2 +// if the file is not found in wasabi, it will return signed url from B2 +func (c *FileController) getWasabiSignedUrlIfAvailable(fileID int64, objType ente.ObjectType) (string, error) { + s3Object, dcs, err := c.ObjectRepo.GetObjectWithDCs(fileID, objType) + if err != nil { + return "", stacktrace.Propagate(err, "") + } + for _, dc := range dcs { + if dc == c.S3Config.GetHotWasabiDC() { + return c.getPreSignedURLForDC(s3Object.ObjectKey, dc) + } + } + // todo: (neeraj) remove this log after some time + log.WithFields(log.Fields{ + "fileID": fileID}).Info("File not found in wasabi, returning signed url from B2") + // return signed url from default hot bucket + return c.getHotDcSignedUrl(s3Object.ObjectKey) } // Trash deletes file and move them to trash @@ -704,7 +732,7 @@ func (c *FileController) cleanupDeletedFile(qItem repo.QueueItem) { ctxLogger.Info("Successfully deleted item") } -func (c *FileController) getPreSignedURL(objectKey string) (string, error) { +func (c *FileController) getHotDcSignedUrl(objectKey string) (string, error) { s3Client := c.S3Config.GetHotS3Client() r, _ := s3Client.GetObjectRequest(&s3.GetObjectInput{ Bucket: c.S3Config.GetHotBucket(), @@ -713,6 +741,15 @@ func (c *FileController) getPreSignedURL(objectKey string) (string, error) { return r.Presign(PreSignedRequestValidityDuration) } +func (c *FileController) getPreSignedURLForDC(objectKey string, dc string) (string, error) { + s3Client := c.S3Config.GetS3Client(dc) + r, _ := s3Client.GetObjectRequest(&s3.GetObjectInput{ + Bucket: c.S3Config.GetBucket(dc), + Key: &objectKey, + }) + return r.Presign(PreSignedRequestValidityDuration) +} + func (c *FileController) sizeOf(objectKey string) (int64, error) { s3Client := c.S3Config.GetHotS3Client() head, err := s3Client.HeadObject(&s3.HeadObjectInput{ diff --git a/server/pkg/controller/user/jwt.go b/server/pkg/controller/user/jwt.go index d920e36b0b..d804f4cef3 100644 --- a/server/pkg/controller/user/jwt.go +++ b/server/pkg/controller/user/jwt.go @@ -13,11 +13,15 @@ import ( const ValidForDays = 1 func (c *UserController) GetJWTToken(userID int64, scope enteJWT.ClaimScope) (string, error) { + tokenExpiry := time.NDaysFromNow(1) + if scope == enteJWT.ACCOUNTS { + tokenExpiry = time.NMinFromNow(30) + } // Create a new token object, specifying signing method and the claims // you would like it to contain. token := jwt.NewWithClaims(jwt.SigningMethodHS256, &enteJWT.WebCommonJWTClaim{ UserID: userID, - ExpiryTime: time.NDaysFromNow(1), + ExpiryTime: tokenExpiry, ClaimScope: &scope, }) // Sign and get the complete encoded token as a string using the secret diff --git a/server/pkg/repo/object.go b/server/pkg/repo/object.go index fdbbbf52c0..052278402d 100644 --- a/server/pkg/repo/object.go +++ b/server/pkg/repo/object.go @@ -64,6 +64,16 @@ func (repo *ObjectRepository) GetObject(fileID int64, objType ente.ObjectType) ( return s3ObjectKey, stacktrace.Propagate(err, "") } +func (repo *ObjectRepository) GetObjectWithDCs(fileID int64, objType ente.ObjectType) (ente.S3ObjectKey, []string, error) { + row := repo.DB.QueryRow(`SELECT object_key, size, o_type, datacenters FROM object_keys WHERE file_id = $1 AND o_type = $2 AND is_deleted=false`, + fileID, objType) + var s3ObjectKey ente.S3ObjectKey + var datacenters []string + s3ObjectKey.FileID = fileID + err := row.Scan(&s3ObjectKey.ObjectKey, &s3ObjectKey.FileSize, &s3ObjectKey.Type, pq.Array(&datacenters)) + return s3ObjectKey, datacenters, stacktrace.Propagate(err, "") +} + func (repo *ObjectRepository) GetAllFileObjectsByObjectKey(objectKey string) ([]ente.S3ObjectKey, error) { rows, err := repo.DB.Query(`SELECT file_id, o_type, object_key, size from object_keys where file_id in (select file_id from object_keys where object_key= $1) diff --git a/server/pkg/repo/passkey/passkey.go b/server/pkg/repo/passkey/passkey.go index 1627ab77d0..131f16b836 100644 --- a/server/pkg/repo/passkey/passkey.go +++ b/server/pkg/repo/passkey/passkey.go @@ -19,6 +19,13 @@ import ( "github.com/go-webauthn/webauthn/webauthn" ) +const ( + // MaxSessionTokenFetchLimit specifies the maximum number of requests a client can make to retrieve token data for a given session ID. + MaxSessionTokenFetchLimit = 2 + // TokenFetchAllowedDurationInMin is the duration in minutes for which the token fetch is allowed after the session is verified. + TokenFetchAllowedDurationInMin = 2 +) + type Repository struct { DB *sql.DB webAuthnInstance *webauthn.WebAuthn @@ -60,9 +67,6 @@ func NewRepository( db *sql.DB, ) (repo *Repository, err error) { rpId := viper.GetString("webauthn.rpid") - if rpId == "" { - rpId = "accounts.ente.io" - } rpOrigins := viper.GetStringSlice("webauthn.rporigins") wconfig := &webauthn.Config{ @@ -72,7 +76,7 @@ func NewRepository( Timeouts: webauthn.TimeoutsConfig{ Login: webauthn.TimeoutConfig{ Enforce: true, - Timeout: time.Duration(5) * time.Minute, + Timeout: time.Duration(2) * time.Minute, }, Registration: webauthn.TimeoutConfig{ Enforce: true, @@ -170,6 +174,87 @@ func (r *Repository) GetUserIDWithPasskeyTwoFactorSession(sessionID string) (use return } +// IsSessionAlreadyClaimed checks if the both token_data and verified_at are not null for a given session ID +func (r *Repository) IsSessionAlreadyClaimed(sessionID string) (bool, error) { + var verifiedAt sql.NullInt64 + err := r.DB.QueryRow(`SELECT verified_at FROM passkey_login_sessions WHERE session_id = $1`, sessionID).Scan(&verifiedAt) + if err != nil { + if err == sql.ErrNoRows { + return false, nil + } + return false, stacktrace.Propagate(err, "") + } + return verifiedAt.Valid, nil +} + +// StoreTokenData takes a sessionID, and tokenData, and updates the tokenData in the database +func (r *Repository) StoreTokenData(sessionID string, tokenData ente.TwoFactorAuthorizationResponse) error { + tokenDataJson, err := json.Marshal(tokenData) + if err != nil { + return stacktrace.Propagate(err, "") + } + _, err = r.DB.Exec(`UPDATE passkey_login_sessions SET token_data = $1, verified_at = now_utc_micro_seconds() WHERE session_id = $2`, tokenDataJson, sessionID) + return stacktrace.Propagate(err, "") +} + +// GetTokenData retrieves the token data associated with a given session ID. +// The function will return the token data if the following conditions are met: +// - The token data is not null. +// - The session was verified less than 5 minutes ago. +// - The token fetch count is less than 2. +// If these conditions are met, the function will also increment the token fetch count by 1. +// +// Parameters: +// - sessionID: The ID of the session for which to retrieve the token data. +// +// Returns: +// - A pointer to a TwoFactorAuthorizationResponse object containing the token data, if the conditions are met. +// - An error, if an error occurred while retrieving the token data or if the conditions are not met. +func (r *Repository) GetTokenData(sessionID string) (*ente.TwoFactorAuthorizationResponse, error) { + var tokenDataJson []byte + var verifiedAt sql.NullInt64 + var fetchCount int + err := r.DB.QueryRow(`SELECT token_data, verified_at, token_fetch_cnt FROM passkey_login_sessions WHERE session_id = $1`, sessionID).Scan(&tokenDataJson, &verifiedAt, &fetchCount) + if err != nil { + if err == sql.ErrNoRows { + return nil, ente.ErrNotFound + } + return nil, stacktrace.Propagate(err, "") + } + if !verifiedAt.Valid { + return nil, &ente.ApiError{ + Code: "SESSION_NOT_VERIFIED", + Message: "Session is not verified yet", + HttpStatusCode: http.StatusBadRequest, + } + } + if verifiedAt.Int64 < ente_time.MicrosecondsBeforeMinutes(TokenFetchAllowedDurationInMin) { + return nil, &ente.ApiError{ + Code: "INVALID_SESSION", + Message: "Session verified but expired now", + HttpStatusCode: http.StatusGone, + } + } + if fetchCount >= MaxSessionTokenFetchLimit { + return nil, &ente.ApiError{ + Code: "INVALID_SESSION", + Message: "Token fetch limit reached", + HttpStatusCode: http.StatusGone, + } + } + var tokenData ente.TwoFactorAuthorizationResponse + err = json.Unmarshal(tokenDataJson, &tokenData) + if err != nil { + return nil, stacktrace.Propagate(err, "") + } + // update the token_fetch_count + _, err = r.DB.Exec(`UPDATE passkey_login_sessions SET token_fetch_cnt = token_fetch_cnt + 1 WHERE session_id = $1`, sessionID) + if err != nil { + return nil, stacktrace.Propagate(err, "") + } + return &tokenData, nil +} + func (r *Repository) CreateBeginAuthenticationData(user *ente.User) (options *protocol.CredentialAssertion, session *webauthn.SessionData, id uuid.UUID, err error) { passkeyUser := &PasskeyUser{ User: user, diff --git a/server/pkg/utils/billing/billing.go b/server/pkg/utils/billing/billing.go index 7301f55bf9..88be8be14d 100644 --- a/server/pkg/utils/billing/billing.go +++ b/server/pkg/utils/billing/billing.go @@ -16,7 +16,7 @@ var ProviderToExpiryGracePeriodMap = map[ente.PaymentProvider]int64{ ente.AppStore: time.MicroSecondsInOneHour * 120, // 5 days ente.Paypal: time.MicroSecondsInOneHour * 120, ente.PlayStore: time.MicroSecondsInOneHour * 120, - ente.Stripe: time.MicroSecondsInOneHour * 120, + ente.Stripe: time.MicroSecondsInOneHour * 336, // 14 days } var CountriesInEU = []string{ diff --git a/server/pkg/utils/time/time.go b/server/pkg/utils/time/time.go index c03f97696d..a07df4b262 100644 --- a/server/pkg/utils/time/time.go +++ b/server/pkg/utils/time/time.go @@ -48,6 +48,11 @@ func NDaysFromNow(n int) int64 { return time.Now().AddDate(0, 0, n).UnixNano() / 1000 } +// NMinFromNow returns the time n min from now in micro seconds +func NMinFromNow(n int64) int64 { + return time.Now().Add(time.Minute*time.Duration(n)).UnixNano() / 1000 +} + // MicrosecondsBeforeMinutes returns the unix time n minutes before now in micro seconds func MicrosecondsBeforeMinutes(noOfMinutes int64) int64 { return Microseconds() - (MicroSecondsInOneMinute * noOfMinutes) diff --git a/web/apps/accounts/.env.development b/web/apps/accounts/.env.development new file mode 100644 index 0000000000..e0cf7acd14 --- /dev/null +++ b/web/apps/accounts/.env.development @@ -0,0 +1,9 @@ +# Copy this file into `.env.local` and uncomment these to develop against apps +# and server running on localhost. +# +# For details, please see `apps/photos/.env.development` + +#NEXT_PUBLIC_ENTE_ENDPOINT = http://localhost:8080 +#NEXT_PUBLIC_ENTE_ALBUMS_ENDPOINT = http://localhost:3002 +#NEXT_PUBLIC_ENTE_ACCOUNTS_URL = http://localhost:3001 +#NEXT_PUBLIC_ENTE_PAYMENTS_URL = http://localhost:3001 diff --git a/web/apps/accounts/.eslintrc.js b/web/apps/accounts/.eslintrc.js index f133a685b7..b1aff972c9 100644 --- a/web/apps/accounts/.eslintrc.js +++ b/web/apps/accounts/.eslintrc.js @@ -1,13 +1,4 @@ module.exports = { - // When root is set to true, ESLint will stop looking for configuration files in parent directories. - // This is required here to ensure desktop picks the right eslint config, where this app is - // packaged as a submodule. - root: true, - extends: ["@ente/eslint-config"], - parser: "@typescript-eslint/parser", - parserOptions: { - tsconfigRootDir: __dirname, - project: "./tsconfig.json", - }, - ignorePatterns: [".eslintrc.js", "out", "next.config.js"], + extends: ["@/build-config/eslintrc-next"], + ignorePatterns: ["next.config.js", "next-env.d.ts"], }; diff --git a/web/apps/accounts/README.md b/web/apps/accounts/README.md new file mode 100644 index 0000000000..82f087f2d4 --- /dev/null +++ b/web/apps/accounts/README.md @@ -0,0 +1,32 @@ +# Ente Accounts + +Code that runs on `accounts.ente.io`. + +Primarily, this serves a common domain where our clients can create and +authenticate using shared passkeys tied to the user's Ente account. + +> [!NOTE] +> +> Passkeys can be shared by multiple subdomains, so we didn't strictly need a +> separate web origin for sharing passkeys between our (photos and auth) web +> clients, but we do need a web origin to handle the passkey flow for the +> desktop and mobile clients. + +For more details about the Passkey flows, +[docs/webauthn-passkeys.md](../../docs/webauthn-passkeys.md). + +## Development + +To set this up to work with a locally running museum, modify your local +`museum.yaml` to set the relaying party's ID to "localhost" (without any port +number). + +```yaml +webauthn: + rpid: "localhost" + rporigins: + - "http://localhost:3001" +``` + +Note that browsers already treat `localhost` as a secure domain, so Passkey APIs +will work even if our local dev server is using `http`. diff --git a/web/apps/accounts/next.config.js b/web/apps/accounts/next.config.js index cffaafdd31..81a64d7ddf 100644 --- a/web/apps/accounts/next.config.js +++ b/web/apps/accounts/next.config.js @@ -1,11 +1 @@ -const nextConfigBase = require("@/next/next.config.base.js"); - -module.exports = { - ...nextConfigBase, - images: { - unoptimized: true, - }, - experimental: { - externalDir: true, - }, -}; +module.exports = require("@/next/next.config.base.js"); diff --git a/web/apps/accounts/package.json b/web/apps/accounts/package.json index 3136b0e809..4599312ddc 100644 --- a/web/apps/accounts/package.json +++ b/web/apps/accounts/package.json @@ -5,7 +5,9 @@ "dependencies": { "@/next": "*", "@ente/accounts": "*", - "@ente/eslint-config": "*", "@ente/shared": "*" + }, + "devDependencies": { + "@/build-config": "*" } } diff --git a/web/apps/accounts/public/images/favicon.png b/web/apps/accounts/public/images/favicon.png index 2d769dadf4..fcb8d1054b 100644 Binary files a/web/apps/accounts/public/images/favicon.png and b/web/apps/accounts/public/images/favicon.png differ diff --git a/web/apps/accounts/public/next.svg b/web/apps/accounts/public/next.svg deleted file mode 100644 index 5174b28c56..0000000000 --- a/web/apps/accounts/public/next.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/web/apps/accounts/public/vercel.svg b/web/apps/accounts/public/vercel.svg deleted file mode 100644 index d2f8422273..0000000000 --- a/web/apps/accounts/public/vercel.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/web/apps/accounts/src/components/context.ts b/web/apps/accounts/src/components/context.ts new file mode 100644 index 0000000000..d6a2755e41 --- /dev/null +++ b/web/apps/accounts/src/components/context.ts @@ -0,0 +1,15 @@ +import type { BaseAppContextT } from "@/next/types/app"; +import { ensure } from "@/utils/ensure"; +import { createContext, useContext } from "react"; + +/** The accounts app has no extra properties on top of the base context. */ +type AppContextT = BaseAppContextT; + +/** The React {@link Context} available to all pages. */ +export const AppContext = createContext(undefined); + +/** + * Utility hook to get the {@link AppContextT}, throwing an exception if it is + * not defined. + */ +export const useAppContext = (): AppContextT => ensure(useContext(AppContext)); diff --git a/web/apps/accounts/src/pages/_app.tsx b/web/apps/accounts/src/pages/_app.tsx index 6fc0936eaf..af73ad8c61 100644 --- a/web/apps/accounts/src/pages/_app.tsx +++ b/web/apps/accounts/src/pages/_app.tsx @@ -1,44 +1,34 @@ import { CustomHead } from "@/next/components/Head"; import { setupI18n } from "@/next/i18n"; import { logUnhandledErrorsAndRejections } from "@/next/log-web"; -import type { AppName, BaseAppContextT } from "@/next/types/app"; -import { ensure } from "@/utils/ensure"; +import { appTitle, type AppName } from "@/next/types/app"; import { PAGES } from "@ente/accounts/constants/pages"; import { accountLogout } from "@ente/accounts/services/logout"; -import { APPS, APP_TITLES } from "@ente/shared/apps/constants"; import { Overlay } from "@ente/shared/components/Container"; import DialogBoxV2 from "@ente/shared/components/DialogBoxV2"; import type { DialogBoxAttributesV2 } from "@ente/shared/components/DialogBoxV2/types"; import EnteSpinner from "@ente/shared/components/EnteSpinner"; import { AppNavbar } from "@ente/shared/components/Navbar/app"; import { useLocalState } from "@ente/shared/hooks/useLocalState"; -import HTTPService from "@ente/shared/network/HTTPService"; -import { LS_KEYS, getData } from "@ente/shared/storage/localStorage"; +import { LS_KEYS } from "@ente/shared/storage/localStorage"; import { getTheme } from "@ente/shared/themes"; import { THEME_COLOR } from "@ente/shared/themes/constants"; import { CssBaseline, useMediaQuery } from "@mui/material"; import { ThemeProvider } from "@mui/material/styles"; +import { AppContext } from "components/context"; import { t } from "i18next"; -import { AppProps } from "next/app"; +import type { AppProps } from "next/app"; import { useRouter } from "next/router"; -import { createContext, useContext, useEffect, useState } from "react"; +import React, { useCallback, useEffect, useState } from "react"; + import "styles/global.css"; -/** The accounts app has no extra properties on top of the base context. */ -type AppContextT = BaseAppContextT; - -/** The React {@link Context} available to all pages. */ -export const AppContext = createContext(undefined); - -/** Utility hook to reduce amount of boilerplate in account related pages. */ -export const useAppContext = () => ensure(useContext(AppContext)); - -export default function App({ Component, pageProps }: AppProps) { - const appName: AppName = "account"; +const App: React.FC = ({ Component, pageProps }) => { + const appName: AppName = "accounts"; const [isI18nReady, setIsI18nReady] = useState(false); - const [showNavbar, setShowNavBar] = useState(false); + const [showNavbar, setShowNavbar] = useState(false); const [dialogBoxAttributeV2, setDialogBoxAttributesV2] = useState< DialogBoxAttributesV2 | undefined @@ -50,55 +40,37 @@ export default function App({ Component, pageProps }: AppProps) { setDialogBoxV2View(true); }, [dialogBoxAttributeV2]); - const showNavBar = (show: boolean) => setShowNavBar(show); - - const isMobile = useMediaQuery("(max-width:428px)"); + const isMobile = useMediaQuery("(max-width: 428px)"); const router = useRouter(); const [themeColor] = useLocalState(LS_KEYS.THEME, THEME_COLOR.DARK); useEffect(() => { - setupI18n().finally(() => setIsI18nReady(true)); + void setupI18n().finally(() => setIsI18nReady(true)); logUnhandledErrorsAndRejections(true); return () => logUnhandledErrorsAndRejections(false); }, []); - const setupPackageName = () => { - const pkg = getData(LS_KEYS.CLIENT_PACKAGE); - if (!pkg) return; - HTTPService.setHeaders({ - "X-Client-Package": pkg.name, - }); - }; - - useEffect(() => { - router.events.on("routeChangeComplete", setupPackageName); - return () => { - router.events.off("routeChangeComplete", setupPackageName); - }; - }, [router.events]); - const closeDialogBoxV2 = () => setDialogBoxV2View(false); - const theme = getTheme(themeColor, APPS.PHOTOS); + const theme = getTheme(themeColor, "photos"); - const logout = () => { + const logout = useCallback(() => { void accountLogout().then(() => router.push(PAGES.ROOT)); - }; + }, [router]); const appContext = { appName, logout, - showNavBar, + showNavBar: setShowNavbar, isMobile, setDialogBoxAttributesV2, }; - // TODO: This string doesn't actually exist const title = isI18nReady ? t("title", { context: "accounts" }) - : APP_TITLES.get(APPS.ACCOUNTS); + : appTitle[appName]; return ( <> @@ -110,7 +82,7 @@ export default function App({ Component, pageProps }: AppProps) { sx={{ zIndex: 1600 }} open={dialogBoxV2View} onClose={closeDialogBoxV2} - attributes={dialogBoxAttributeV2 as any} + attributes={dialogBoxAttributeV2} /> @@ -121,8 +93,7 @@ export default function App({ Component, pageProps }: AppProps) { justifyContent: "center", alignItems: "center", zIndex: 2000, - backgroundColor: (theme as any).colors - .background.base, + backgroundColor: theme.colors.background.base, })} > @@ -134,4 +105,6 @@ export default function App({ Component, pageProps }: AppProps) { ); -} +}; + +export default App; diff --git a/web/apps/accounts/src/pages/account-handoff.tsx b/web/apps/accounts/src/pages/account-handoff.tsx deleted file mode 100644 index 45d8fa9682..0000000000 --- a/web/apps/accounts/src/pages/account-handoff.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import log from "@/next/log"; -import { VerticallyCentered } from "@ente/shared/components/Container"; -import EnteSpinner from "@ente/shared/components/EnteSpinner"; -import { ACCOUNTS_PAGES } from "@ente/shared/constants/pages"; -import HTTPService from "@ente/shared/network/HTTPService"; -import { LS_KEYS, getData, setData } from "@ente/shared/storage/localStorage"; -import { useRouter } from "next/router"; -import { useEffect } from "react"; - -const AccountHandoff = () => { - const router = useRouter(); - - const retrieveAccountData = () => { - try { - extractAccountsToken(); - - router.push(ACCOUNTS_PAGES.PASSKEYS); - } catch (e) { - log.error("Failed to deserialize and set passed user data", e); - router.push(ACCOUNTS_PAGES.LOGIN); - } - }; - - const getClientPackageName = () => { - const urlParams = new URLSearchParams(window.location.search); - const pkg = urlParams.get("package"); - if (!pkg) return; - setData(LS_KEYS.CLIENT_PACKAGE, { name: pkg }); - HTTPService.setHeaders({ - "X-Client-Package": pkg, - }); - }; - - const extractAccountsToken = () => { - const urlParams = new URLSearchParams(window.location.search); - const token = urlParams.get("token"); - if (!token) { - throw new Error("token not found"); - } - - const user = getData(LS_KEYS.USER) || {}; - user.token = token; - - setData(LS_KEYS.USER, user); - }; - - useEffect(() => { - getClientPackageName(); - retrieveAccountData(); - }, []); - - return ( - - - - ); -}; - -export default AccountHandoff; diff --git a/web/apps/accounts/src/pages/credentials.tsx b/web/apps/accounts/src/pages/credentials.tsx deleted file mode 100644 index 070aace4a1..0000000000 --- a/web/apps/accounts/src/pages/credentials.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import Page_ from "@ente/accounts/pages/credentials"; -import { useAppContext } from "./_app"; - -const Page = () => ; - -export default Page; diff --git a/web/apps/accounts/src/pages/generate.tsx b/web/apps/accounts/src/pages/generate.tsx deleted file mode 100644 index c6804255af..0000000000 --- a/web/apps/accounts/src/pages/generate.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import Page_ from "@ente/accounts/pages/generate"; -import { useAppContext } from "./_app"; - -const Page = () => ; - -export default Page; diff --git a/web/apps/accounts/src/pages/index.tsx b/web/apps/accounts/src/pages/index.tsx index f9538e05db..cb6d2b20f1 100644 --- a/web/apps/accounts/src/pages/index.tsx +++ b/web/apps/accounts/src/pages/index.tsx @@ -1,13 +1,12 @@ -import { useRouter } from "next/router"; -import { useEffect } from "react"; - -const Index = () => { - const router = useRouter(); +import React, { useEffect } from "react"; +const Page: React.FC = () => { useEffect(() => { - router.push("/login"); + // There are no user navigable pages currently on accounts.ente.io. + window.location.href = "https://web.ente.io"; }, []); + return <>; }; -export default Index; +export default Page; diff --git a/web/apps/accounts/src/pages/login.tsx b/web/apps/accounts/src/pages/login.tsx deleted file mode 100644 index 1a7de0497f..0000000000 --- a/web/apps/accounts/src/pages/login.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import Page_ from "@ente/accounts/pages/login"; -import { useAppContext } from "./_app"; - -const Page = () => ; - -export default Page; diff --git a/web/apps/accounts/src/pages/passkeys/DeletePasskeyModal.tsx b/web/apps/accounts/src/pages/passkeys/DeletePasskeyModal.tsx deleted file mode 100644 index f0feb0dbd6..0000000000 --- a/web/apps/accounts/src/pages/passkeys/DeletePasskeyModal.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import DialogBoxV2 from "@ente/shared/components/DialogBoxV2"; -import EnteButton from "@ente/shared/components/EnteButton"; -import { Button, Stack, Typography } from "@mui/material"; -import { t } from "i18next"; -import { AppContext } from "pages/_app"; -import { useContext, useState } from "react"; -import { deletePasskey } from "services/passkeysService"; -import { PasskeysContext } from "."; - -interface IProps { - open: boolean; - onClose: () => void; -} - -const DeletePasskeyModal = (props: IProps) => { - const { isMobile } = useContext(AppContext); - const { selectedPasskey, setShowPasskeyDrawer } = - useContext(PasskeysContext); - - const [loading, setLoading] = useState(false); - - const doDelete = async () => { - if (!selectedPasskey) return; - setLoading(true); - try { - await deletePasskey(selectedPasskey.id); - } catch (error) { - console.error(error); - return; - } finally { - setLoading(false); - } - props.onClose(); - setShowPasskeyDrawer(false); - }; - - return ( - - - {t("DELETE_PASSKEY_CONFIRMATION")} - - {t("DELETE")} - - - - - ); -}; - -export default DeletePasskeyModal; diff --git a/web/apps/accounts/src/pages/passkeys/ManagePasskeyDrawer.tsx b/web/apps/accounts/src/pages/passkeys/ManagePasskeyDrawer.tsx deleted file mode 100644 index 4e98748755..0000000000 --- a/web/apps/accounts/src/pages/passkeys/ManagePasskeyDrawer.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { EnteDrawer } from "@ente/shared/components/EnteDrawer"; -import InfoItem from "@ente/shared/components/Info/InfoItem"; -import { EnteMenuItem } from "@ente/shared/components/Menu/EnteMenuItem"; -import MenuItemDivider from "@ente/shared/components/Menu/MenuItemDivider"; -import { MenuItemGroup } from "@ente/shared/components/Menu/MenuItemGroup"; -import Titlebar from "@ente/shared/components/Titlebar"; -import { formatDateTimeFull } from "@ente/shared/time/format"; -import CalendarTodayIcon from "@mui/icons-material/CalendarToday"; -import DeleteIcon from "@mui/icons-material/Delete"; -import EditIcon from "@mui/icons-material/Edit"; -import { Stack } from "@mui/material"; -import { t } from "i18next"; -import { useContext, useState } from "react"; -import { PasskeysContext } from "."; -import DeletePasskeyModal from "./DeletePasskeyModal"; -import RenamePasskeyModal from "./RenamePasskeyModal"; - -interface IProps { - open: boolean; -} - -const ManagePasskeyDrawer = (props: IProps) => { - const { setShowPasskeyDrawer, refreshPasskeys, selectedPasskey } = - useContext(PasskeysContext); - - const [showDeletePasskeyModal, setShowDeletePasskeyModal] = useState(false); - const [showRenamePasskeyModal, setShowRenamePasskeyModal] = useState(false); - - return ( - <> - { - setShowPasskeyDrawer(false); - }} - > - {selectedPasskey && ( - <> - - { - setShowPasskeyDrawer(false); - }} - title="Manage Passkey" - onRootClose={() => { - setShowPasskeyDrawer(false); - }} - /> - } - title={t("CREATED_AT")} - caption={ - `${formatDateTimeFull( - selectedPasskey.createdAt / 1000, - )}` || "" - } - loading={!selectedPasskey} - hideEditOption - /> - - { - setShowRenamePasskeyModal(true); - }} - startIcon={} - label={"Rename Passkey"} - /> - - { - setShowDeletePasskeyModal(true); - }} - startIcon={} - label={"Delete Passkey"} - color="critical" - /> - - - - )} - - { - setShowDeletePasskeyModal(false); - refreshPasskeys(); - }} - /> - { - setShowRenamePasskeyModal(false); - refreshPasskeys(); - }} - /> - - ); -}; - -export default ManagePasskeyDrawer; diff --git a/web/apps/accounts/src/pages/passkeys/PasskeyListItem.tsx b/web/apps/accounts/src/pages/passkeys/PasskeyListItem.tsx deleted file mode 100644 index d38a22044d..0000000000 --- a/web/apps/accounts/src/pages/passkeys/PasskeyListItem.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { EnteMenuItem } from "@ente/shared/components/Menu/EnteMenuItem"; -import ChevronRightIcon from "@mui/icons-material/ChevronRight"; -import KeyIcon from "@mui/icons-material/Key"; -import { useContext } from "react"; -import { Passkey } from "types/passkey"; -import { PasskeysContext } from "."; - -interface IProps { - passkey: Passkey; -} - -const PasskeyListItem = (props: IProps) => { - const { setSelectedPasskey, setShowPasskeyDrawer } = - useContext(PasskeysContext); - - return ( - { - setSelectedPasskey(props.passkey); - setShowPasskeyDrawer(true); - }} - startIcon={} - endIcon={} - label={props.passkey?.friendlyName} - /> - ); -}; - -export default PasskeyListItem; diff --git a/web/apps/accounts/src/pages/passkeys/PasskeysList.tsx b/web/apps/accounts/src/pages/passkeys/PasskeysList.tsx deleted file mode 100644 index fd2c1538bb..0000000000 --- a/web/apps/accounts/src/pages/passkeys/PasskeysList.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import MenuItemDivider from "@ente/shared/components/Menu/MenuItemDivider"; -import { MenuItemGroup } from "@ente/shared/components/Menu/MenuItemGroup"; -import { Fragment } from "react"; -import { Passkey } from "types/passkey"; -import PasskeyListItem from "./PasskeyListItem"; - -interface IProps { - passkeys: Passkey[]; -} - -const PasskeyComponent = (props: IProps) => { - return ( - <> - - {props.passkeys?.map((passkey, i) => ( - - - {i < props.passkeys.length - 1 && } - - ))} - - - ); -}; - -export default PasskeyComponent; diff --git a/web/apps/accounts/src/pages/passkeys/RenamePasskeyModal.tsx b/web/apps/accounts/src/pages/passkeys/RenamePasskeyModal.tsx deleted file mode 100644 index ad4f4a2f55..0000000000 --- a/web/apps/accounts/src/pages/passkeys/RenamePasskeyModal.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import DialogBoxV2 from "@ente/shared/components/DialogBoxV2"; -import SingleInputForm from "@ente/shared/components/SingleInputForm"; -import { t } from "i18next"; -import { AppContext } from "pages/_app"; -import { useContext } from "react"; -import { renamePasskey } from "services/passkeysService"; -import { PasskeysContext } from "."; - -interface IProps { - open: boolean; - onClose: () => void; -} - -const RenamePasskeyModal = (props: IProps) => { - const { isMobile } = useContext(AppContext); - const { selectedPasskey } = useContext(PasskeysContext); - - const onSubmit = async (inputValue: string) => { - if (!selectedPasskey) return; - try { - await renamePasskey(selectedPasskey.id, inputValue); - } catch (error) { - console.error(error); - return; - } - - props.onClose(); - }; - - return ( - - - - ); -}; - -export default RenamePasskeyModal; diff --git a/web/apps/accounts/src/pages/passkeys/finish.tsx b/web/apps/accounts/src/pages/passkeys/finish.tsx deleted file mode 100644 index 11f03ee5c2..0000000000 --- a/web/apps/accounts/src/pages/passkeys/finish.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import PasskeysFinishPage from "@ente/accounts/pages/passkeys/finish"; -const PasskeysFinish = () => { - return ; -}; - -export default PasskeysFinish; diff --git a/web/apps/accounts/src/pages/passkeys/flow/Recover.tsx b/web/apps/accounts/src/pages/passkeys/flow/Recover.tsx deleted file mode 100644 index 30e1f63afb..0000000000 --- a/web/apps/accounts/src/pages/passkeys/flow/Recover.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { TwoFactorType } from "@ente/accounts/constants/twofactor"; -import RecoverPage from "@ente/accounts/pages/two-factor/recover"; -import { useAppContext } from "../../_app"; - -const Page = () => ( - -); - -export default Page; diff --git a/web/apps/accounts/src/pages/passkeys/flow/index.tsx b/web/apps/accounts/src/pages/passkeys/flow/index.tsx deleted file mode 100644 index baf44c7e3d..0000000000 --- a/web/apps/accounts/src/pages/passkeys/flow/index.tsx +++ /dev/null @@ -1,313 +0,0 @@ -import log from "@/next/log"; -import { APPS, CLIENT_PACKAGE_NAMES } from "@ente/shared/apps/constants"; -import { - CenteredFlex, - VerticallyCentered, -} from "@ente/shared/components/Container"; -import EnteButton from "@ente/shared/components/EnteButton"; -import EnteSpinner from "@ente/shared/components/EnteSpinner"; -import FormPaper from "@ente/shared/components/Form/FormPaper"; -import HTTPService from "@ente/shared/network/HTTPService"; -import { LS_KEYS, setData } from "@ente/shared/storage/localStorage"; -import InfoIcon from "@mui/icons-material/Info"; -import { Box, Typography } from "@mui/material"; -import { t } from "i18next"; -import _sodium from "libsodium-wrappers"; -import { useEffect, useState } from "react"; -import { - BeginPasskeyAuthenticationResponse, - beginPasskeyAuthentication, - finishPasskeyAuthentication, -} from "services/passkeysService"; - -const PasskeysFlow = () => { - const [errored, setErrored] = useState(false); - - const [invalidInfo, setInvalidInfo] = useState(false); - - const [loading, setLoading] = useState(true); - - const init = async () => { - const searchParams = new URLSearchParams(window.location.search); - - // get redirect from the query params - const redirect = searchParams.get("redirect") as string; - - const redirectURL = new URL(redirect); - if (process.env.NEXT_PUBLIC_DISABLE_REDIRECT_CHECK !== "true") { - if ( - redirect !== "" && - !( - redirectURL.host.endsWith(".ente.io") || - redirectURL.host.endsWith(".ente.sh") || - redirectURL.host.endsWith("bada-frame.pages.dev") - ) && - redirectURL.protocol !== "ente:" && - redirectURL.protocol !== "enteauth:" - ) { - setInvalidInfo(true); - setLoading(false); - return; - } - } - - let pkg = CLIENT_PACKAGE_NAMES.get(APPS.PHOTOS); - if (redirectURL.protocol === "enteauth:") { - pkg = CLIENT_PACKAGE_NAMES.get(APPS.AUTH); - } else if (redirectURL.hostname.startsWith("accounts")) { - pkg = CLIENT_PACKAGE_NAMES.get(APPS.ACCOUNTS); - } - - setData(LS_KEYS.CLIENT_PACKAGE, { name: pkg }); - HTTPService.setHeaders({ - "X-Client-Package": pkg, - }); - - // get passkeySessionID from the query params - const passkeySessionID = searchParams.get("passkeySessionID") as string; - - setLoading(true); - - let beginData: BeginPasskeyAuthenticationResponse; - - try { - beginData = await beginAuthentication(passkeySessionID); - } catch (e) { - log.error("Couldn't begin passkey authentication", e); - setErrored(true); - return; - } finally { - setLoading(false); - } - - let credential: Credential | null = null; - - let tries = 0; - const maxTries = 3; - - while (tries < maxTries) { - try { - credential = await getCredential(beginData.options.publicKey); - } catch (e) { - log.error("Couldn't get credential", e); - continue; - } finally { - tries++; - } - - break; - } - - if (!credential) { - if (!isWebAuthnSupported()) { - alert("WebAuthn is not supported in this browser"); - } - setErrored(true); - return; - } - - setLoading(true); - - let finishData; - - try { - finishData = await finishAuthentication( - credential, - passkeySessionID, - beginData.ceremonySessionID, - ); - } catch (e) { - log.error("Couldn't finish passkey authentication", e); - setErrored(true); - setLoading(false); - return; - } - - const encodedResponse = _sodium.to_base64(JSON.stringify(finishData)); - - window.location.href = `${redirect}?response=${encodedResponse}`; - }; - - const beginAuthentication = async (sessionId: string) => { - const data = await beginPasskeyAuthentication(sessionId); - return data; - }; - - function isWebAuthnSupported(): boolean { - if (!navigator.credentials) { - return false; - } - return true; - } - - const getCredential = async ( - publicKey: any, - timeoutMillis: number = 60000, // Default timeout of 60 seconds - ): Promise => { - publicKey.challenge = _sodium.from_base64( - publicKey.challenge, - _sodium.base64_variants.URLSAFE_NO_PADDING, - ); - publicKey.allowCredentials?.forEach(function (listItem: any) { - listItem.id = _sodium.from_base64( - listItem.id, - _sodium.base64_variants.URLSAFE_NO_PADDING, - ); - // note: we are orverwriting the transports array with all possible values. - // This is because the browser will only prompt the user for the transport that is available. - // Warning: In case of invalid transport value, the webauthn will fail on Safari & iOS browsers - listItem.transports = ["usb", "nfc", "ble", "internal"]; - }); - publicKey.timeout = timeoutMillis; - const publicKeyCredentialCreationOptions: CredentialRequestOptions = { - publicKey: publicKey, - }; - const credential = await navigator.credentials.get( - publicKeyCredentialCreationOptions, - ); - return credential; - }; - - const finishAuthentication = async ( - credential: Credential, - sessionId: string, - ceremonySessionId: string, - ) => { - const data = await finishPasskeyAuthentication( - credential, - sessionId, - ceremonySessionId, - ); - return data; - }; - - useEffect(() => { - init(); - }, []); - - if (loading) { - return ( - - - - ); - } - - if (invalidInfo) { - return ( - - - - - - {t("PASSKEY_LOGIN_FAILED")} - - - {t("PASSKEY_LOGIN_URL_INVALID")} - - - - - ); - } - - if (errored) { - return ( - - - - - - {t("PASSKEY_LOGIN_FAILED")} - - - {t("PASSKEY_LOGIN_ERRORED")} - - { - setErrored(false); - init(); - }} - fullWidth - style={{ - marginTop: "1rem", - }} - color="primary" - type="button" - variant="contained" - > - {t("TRY_AGAIN")} - - - {t("RECOVER_TWO_FACTOR")} - - - - - ); - } - - return ( - <> - - - - - - {t("LOGIN_WITH_PASSKEY")} - - - {t("PASSKEY_FOLLOW_THE_STEPS_FROM_YOUR_BROWSER")} - - - ente Logo Circular - - - - - - ); -}; - -export default PasskeysFlow; diff --git a/web/apps/accounts/src/pages/passkeys/index.tsx b/web/apps/accounts/src/pages/passkeys/index.tsx index dec8916cc2..0a3d06a6cb 100644 --- a/web/apps/accounts/src/pages/passkeys/index.tsx +++ b/web/apps/accounts/src/pages/passkeys/index.tsx @@ -1,165 +1,432 @@ import log from "@/next/log"; +import { ensure } from "@/utils/ensure"; import { CenteredFlex } from "@ente/shared/components/Container"; +import DialogBoxV2 from "@ente/shared/components/DialogBoxV2"; +import EnteButton from "@ente/shared/components/EnteButton"; +import { EnteDrawer } from "@ente/shared/components/EnteDrawer"; import FormPaper from "@ente/shared/components/Form/FormPaper"; +import InfoItem from "@ente/shared/components/Info/InfoItem"; +import { EnteMenuItem } from "@ente/shared/components/Menu/EnteMenuItem"; +import MenuItemDivider from "@ente/shared/components/Menu/MenuItemDivider"; +import { MenuItemGroup } from "@ente/shared/components/Menu/MenuItemGroup"; import SingleInputForm from "@ente/shared/components/SingleInputForm"; -import { ACCOUNTS_PAGES } from "@ente/shared/constants/pages"; -import { getToken } from "@ente/shared/storage/localStorage/helpers"; -import { Box, Typography } from "@mui/material"; +import Titlebar from "@ente/shared/components/Titlebar"; +import { formatDateTimeFull } from "@ente/shared/time/format"; +import CalendarTodayIcon from "@mui/icons-material/CalendarToday"; +import ChevronRightIcon from "@mui/icons-material/ChevronRight"; +import DeleteIcon from "@mui/icons-material/Delete"; +import EditIcon from "@mui/icons-material/Edit"; +import KeyIcon from "@mui/icons-material/Key"; +import { Box, Button, Stack, Typography, useMediaQuery } from "@mui/material"; +import { useAppContext } from "components/context"; import { t } from "i18next"; -import _sodium from "libsodium-wrappers"; -import { useRouter } from "next/router"; -import { AppContext } from "pages/_app"; -import type { Dispatch, SetStateAction } from "react"; -import { createContext, useContext, useEffect, useState } from "react"; -import { Passkey } from "types/passkey"; +import React, { useCallback, useEffect, useState } from "react"; import { - finishPasskeyRegistration, - getPasskeyRegistrationOptions, + deletePasskey, getPasskeys, -} from "../../services/passkeysService"; -import ManagePasskeyDrawer from "./ManagePasskeyDrawer"; -import PasskeysList from "./PasskeysList"; + registerPasskey, + renamePasskey, + type Passkey, +} from "services/passkey"; -export const PasskeysContext = createContext( - {} as { - selectedPasskey: Passkey | null; - setSelectedPasskey: Dispatch>; - setShowPasskeyDrawer: Dispatch>; - refreshPasskeys: () => void; - }, -); - -const Passkeys = () => { - const { showNavBar } = useContext(AppContext); - - const [selectedPasskey, setSelectedPasskey] = useState( - null, - ); - - const [showPasskeyDrawer, setShowPasskeyDrawer] = useState(false); +const Page: React.FC = () => { + const { showNavBar, setDialogBoxAttributesV2 } = useAppContext(); + const [token, setToken] = useState(); const [passkeys, setPasskeys] = useState([]); + const [showPasskeyDrawer, setShowPasskeyDrawer] = useState(false); + const [selectedPasskey, setSelectedPasskey] = useState< + Passkey | undefined + >(); - const router = useRouter(); - - const checkLoggedIn = () => { - const token = getToken(); - if (!token) { - router.push(ACCOUNTS_PAGES.LOGIN); - } - }; - - const init = async () => { - checkLoggedIn(); - const data = await getPasskeys(); - setPasskeys(data.passkeys || []); - }; + const showPasskeyFetchFailedErrorDialog = useCallback(() => { + setDialogBoxAttributesV2({ + title: t("ERROR"), + content: t("passkey_fetch_failed"), + close: {}, + }); + }, [setDialogBoxAttributesV2]); useEffect(() => { showNavBar(true); - init(); - }, []); + + const urlParams = new URLSearchParams(window.location.search); + + const token = urlParams.get("token"); + if (token) { + setToken(token); + } else { + log.error("Missing accounts token"); + showPasskeyFetchFailedErrorDialog(); + } + }, [showNavBar, showPasskeyFetchFailedErrorDialog]); + + const refreshPasskeys = useCallback(async () => { + try { + setPasskeys(await getPasskeys(ensure(token))); + } catch (e) { + log.error("Failed to fetch passkeys", e); + showPasskeyFetchFailedErrorDialog(); + } + }, [token, showPasskeyFetchFailedErrorDialog]); + + useEffect(() => { + if (token) { + void refreshPasskeys(); + } + }, [token, refreshPasskeys]); + + const handleSelectPasskey = (passkey: Passkey) => { + setSelectedPasskey(passkey); + setShowPasskeyDrawer(true); + }; + + const handleDrawerClose = () => { + setShowPasskeyDrawer(false); + // Don't clear the selected passkey, let the stale value be so that the + // drawer closing animation is nicer. + // + // The value will get overwritten the next time we open the drawer for a + // different passkey, so this will not have a functional impact. + }; + + const handleUpdateOrDeletePasskey = () => { + setShowPasskeyDrawer(false); + setSelectedPasskey(undefined); + void refreshPasskeys(); + }; const handleSubmit = async ( inputValue: string, setFieldError: (errorMessage: string) => void, - resetForm: (nextState?: unknown) => void, + resetForm: () => void, ) => { - let response: { - options: { - publicKey: PublicKeyCredentialCreationOptions; - }; - sessionID: string; - }; - try { - response = await getPasskeyRegistrationOptions(); - } catch { - setFieldError("Failed to begin registration"); - return; - } - - const options = response.options; - - options.publicKey.challenge = _sodium.from_base64( - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - options.publicKey.challenge, - ); - options.publicKey.user.id = _sodium.from_base64( - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - options.publicKey.user.id, - ); - - // create new credential - let newCredential: Credential | null = null; - - try { - newCredential = await navigator.credentials.create(options); + await registerPasskey(ensure(token), inputValue); } catch (e) { - log.error("Error creating credential", e); - setFieldError("Failed to create credential"); + log.error("Failed to register a new passkey", e); + // If the user cancels the operation, then an error with name + // "NotAllowedError" is thrown. + // + // Ignore these, but in other cases add an error indicator to the + // add passkey text field. The browser is expected to already have + // shown an error dialog to the user. + if (!(e instanceof Error && e.name == "NotAllowedError")) { + setFieldError(t("passkey_add_failed")); + } return; } - - try { - await finishPasskeyRegistration( - inputValue, - newCredential, - response.sessionID, - ); - } catch { - setFieldError("Failed to finish registration"); - return; - } - - await init(); + await refreshPasskeys(); resetForm(); }; return ( <> - - - - - {t("PASSKEYS_DESCRIPTION")} - - - - - - - + + + + {t("passkeys_description")} - - - + + + + + + + + + + ); }; -export default Passkeys; +export default Page; + +interface PasskeysListProps { + /** The list of {@link Passkey}s to show. */ + passkeys: Passkey[]; + /** + * Callback to invoke when an passkey in the list is clicked. + * + * It is passed the corresponding {@link Passkey}. + */ + onSelectPasskey: (passkey: Passkey) => void; +} + +const PasskeysList: React.FC = ({ + passkeys, + onSelectPasskey, +}) => { + return ( + + {passkeys.map((passkey, i) => ( + + + {i < passkeys.length - 1 && } + + ))} + + ); +}; + +interface PasskeyListItemProps { + /** The passkey to show in the item. */ + passkey: Passkey; + /** + * Callback to invoke when the item is clicked. + * + * It is passed the item's {@link passkey}. + */ + onClick: (passkey: Passkey) => void; +} + +const PasskeyListItem: React.FC = ({ + passkey, + onClick, +}) => { + return ( + onClick(passkey)} + startIcon={} + endIcon={} + label={passkey.friendlyName} + /> + ); +}; + +interface ManagePasskeyDrawerProps { + /** If `true`, then the drawer is shown. */ + open: boolean; + /** Callback to invoke when the drawer wants to be closed. */ + onClose: () => void; + /** + * The token to use for authenticating with the backend when making requests + * for editing or deleting passkeys. + * + * It is guaranteed that this will be defined when `open` is true. + */ + token: string | undefined; + /** + * The {@link Passkey} whose details should be shown in the drawer. + * + * It is guaranteed that this will be defined when `open` is true. + */ + passkey: Passkey | undefined; + /** + * Callback to invoke when the passkey in the modifed or deleted. + * + * The passkey that the drawer is showing will be out of date at this point, + * so the list of passkeys should be refreshed and the drawer closed. + */ + onUpdateOrDeletePasskey: () => void; +} + +const ManagePasskeyDrawer: React.FC = ({ + open, + onClose, + token, + passkey, + onUpdateOrDeletePasskey, +}) => { + const [showRenameDialog, setShowRenameDialog] = useState(false); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + + return ( + <> + + {token && passkey && ( + + + } + title={t("CREATED_AT")} + caption={formatDateTimeFull( + passkey.createdAt / 1000, + )} + loading={false} + hideEditOption + /> + + { + setShowRenameDialog(true); + }} + startIcon={} + label={t("rename_passkey")} + /> + + { + setShowDeleteDialog(true); + }} + startIcon={} + label={t("delete_passkey")} + color="critical" + /> + + + )} + + + {token && passkey && ( + setShowRenameDialog(false)} + token={token} + passkey={passkey} + onRenamePasskey={() => { + setShowRenameDialog(false); + onUpdateOrDeletePasskey(); + }} + /> + )} + + {token && passkey && ( + setShowDeleteDialog(false)} + token={token} + passkey={passkey} + onDeletePasskey={() => { + setShowDeleteDialog(false); + onUpdateOrDeletePasskey(); + }} + /> + )} + + ); +}; + +interface RenamePasskeyDialogProps { + /** If `true`, then the dialog is shown. */ + open: boolean; + /** Callback to invoke when the dialog wants to be closed. */ + onClose: () => void; + /** Auth token for API requests. */ + token: string; + /** The {@link Passkey} to rename. */ + passkey: Passkey; + /** Callback to invoke when the passkey is renamed. */ + onRenamePasskey: () => void; +} + +const RenamePasskeyDialog: React.FC = ({ + open, + onClose, + token, + passkey, + onRenamePasskey, +}) => { + const fullScreen = useMediaQuery("(max-width: 428px)"); + + const handleSubmit = async (inputValue: string) => { + try { + await renamePasskey(token, passkey.id, inputValue); + onRenamePasskey(); + } catch (e) { + log.error("Failed to rename passkey", e); + } + }; + + return ( + + + + ); +}; + +interface DeletePasskeyDialogProps { + /** If `true`, then the dialog is shown. */ + open: boolean; + /** Callback to invoke when the dialog wants to be closed. */ + onClose: () => void; + /** Auth token for API requests. */ + token: string; + /** The {@link Passkey} to delete. */ + passkey: Passkey; + /** Callback to invoke when the passkey is deleted. */ + onDeletePasskey: () => void; +} + +const DeletePasskeyDialog: React.FC = ({ + open, + onClose, + token, + passkey, + onDeletePasskey, +}) => { + const [isDeleting, setIsDeleting] = useState(false); + const fullScreen = useMediaQuery("(max-width: 428px)"); + + const handleConfirm = async () => { + setIsDeleting(true); + try { + await deletePasskey(token, passkey.id); + onDeletePasskey(); + } catch (e) { + log.error("Failed to delete passkey", e); + } finally { + setIsDeleting(false); + } + }; + + return ( + + + {t("delete_passkey_confirmation")} + + {t("DELETE")} + + + + + ); +}; diff --git a/web/apps/accounts/src/pages/passkeys/verify.tsx b/web/apps/accounts/src/pages/passkeys/verify.tsx new file mode 100644 index 0000000000..c9ea3289d9 --- /dev/null +++ b/web/apps/accounts/src/pages/passkeys/verify.tsx @@ -0,0 +1,519 @@ +import log from "@/next/log"; +import type { TwoFactorAuthorizationResponse } from "@/next/types/credentials"; +import { ensure } from "@/utils/ensure"; +import { nullToUndefined } from "@/utils/transform"; +import { VerticallyCentered } from "@ente/shared/components/Container"; +import EnteButton from "@ente/shared/components/EnteButton"; +import EnteSpinner from "@ente/shared/components/EnteSpinner"; +import InfoIcon from "@mui/icons-material/Info"; +import KeyIcon from "@mui/icons-material/Key"; +import { Paper, Typography, styled } from "@mui/material"; +import { t } from "i18next"; +import React, { useCallback, useEffect, useState } from "react"; +import { + beginPasskeyAuthentication, + finishPasskeyAuthentication, + isWebAuthnSupported, + isWhitelistedRedirect, + passkeyAuthenticationSuccessRedirectURL, + passkeySessionAlreadyClaimedErrorMessage, + redirectToPasskeyRecoverPage, + signChallenge, + type BeginPasskeyAuthenticationResponse, +} from "services/passkey"; + +const Page = () => { + /** + * The state of our component as we go through the passkey authentication + * flow. + * + * To avoid confusion with useState, we call it status instead. */ + type Status = + | "loading" /* Can happen multiple times in the flow */ + | "webAuthnNotSupported" /* Unrecoverable error */ + | "unknownRedirect" /* Unrecoverable error */ + | "sessionAlreadyClaimed" /* Unrecoverable error */ + | "unrecoverableFailure" /* Unrecoverable error - generic */ + | "failedDuringSignChallenge" /* Recoverable error in signChallenge */ + | "failed" /* Recoverable error otherwise */ + | "needUserFocus" /* See docs for `Continuation` */ + | "waitingForUser" /* ...to authenticate with their passkey */ + | "redirectingWeb" /* Redirect back to the requesting app (HTTP) */ + | "redirectingApp"; /* Other redirects (mobile / desktop redirect) */ + + const [status, setStatus] = useState("loading"); + + /** + * Safari keeps on saying "NotAllowedError: The document is not focused" + * even though it just opened the page and brought it to the front. + * + * Because of their incompetence, we need to break our entire flow into two + * parts, and stash away a lot of state when we're in the "needUserFocus" + * state. + */ + interface Continuation { + redirectURL: URL; + clientPackage: string; + passkeySessionID: string; + beginResponse: BeginPasskeyAuthenticationResponse; + } + const [continuation, setContinuation] = useState< + Continuation | undefined + >(); + + // Safari throws sometimes + // (no reason, just to show their incompetence). The retry doesn't seem to + // help mostly, but cargo cult anyway. + + // The URL we're redirecting to on success. + // + // This will only be set when status is "redirecting*". + const [successRedirectURL, setSuccessRedirectURL] = useState< + URL | undefined + >(); + + /** Phase 1 of {@link authenticate}. */ + const authenticateBegin = useCallback(async () => { + if (!isWebAuthnSupported()) { + setStatus("webAuthnNotSupported"); + return; + } + + const searchParams = new URLSearchParams(window.location.search); + + // Extract redirect from the query params. + const redirect = nullToUndefined(searchParams.get("redirect")); + const redirectURL = redirect ? new URL(redirect) : undefined; + + // Ensure that redirectURL is whitelisted, otherwise show an invalid + // "login" URL error to the user. + if (!redirectURL || !isWhitelistedRedirect(redirectURL)) { + log.error(`Redirect '${redirect}' is not whitelisted`); + setStatus("unknownRedirect"); + return; + } + + // The server needs to know the app on whose behalf we're trying to + // authenticate. + const clientPackage = nullToUndefined( + searchParams.get("clientPackage"), + ); + if (!clientPackage) { + setStatus("unrecoverableFailure"); + return; + } + + setStatus("loading"); + + // Extract passkeySessionID from the query params. + const passkeySessionID = nullToUndefined( + searchParams.get("passkeySessionID"), + ); + if (!passkeySessionID) { + setStatus("unrecoverableFailure"); + return; + } + + let beginResponse: BeginPasskeyAuthenticationResponse; + try { + beginResponse = await beginPasskeyAuthentication(passkeySessionID); + } catch (e) { + log.error("Failed to begin passkey authentication", e); + setStatus( + e instanceof Error && + e.message == passkeySessionAlreadyClaimedErrorMessage + ? "sessionAlreadyClaimed" + : "failed", + ); + return; + } + + return { + redirectURL, + passkeySessionID, + clientPackage, + beginResponse, + }; + }, []); + + /** + * Phase 2 of {@link authenticate}, separated by a potential user + * interaction. + */ + const authenticateContinue = useCallback(async (cont: Continuation) => { + const { redirectURL, passkeySessionID, clientPackage, beginResponse } = + cont; + const { ceremonySessionID, options } = beginResponse; + + setStatus("waitingForUser"); + + let credential: Credential | undefined; + try { + credential = await signChallenge(options.publicKey); + if (!credential) { + setStatus("failedDuringSignChallenge"); + return; + } + } catch (e) { + log.error("Failed to get credentials", e); + if ( + e instanceof Error && + e.name == "NotAllowedError" && + e.message == "The document is not focused." + ) { + setStatus("needUserFocus"); + } else { + setStatus("failedDuringSignChallenge"); + } + return; + } + + setStatus("loading"); + + let authorizationResponse: TwoFactorAuthorizationResponse; + try { + authorizationResponse = await finishPasskeyAuthentication({ + passkeySessionID, + ceremonySessionID, + clientPackage, + credential, + }); + } catch (e) { + log.error("Failed to finish passkey authentication", e); + setStatus("failed"); + return; + } + + setStatus(isHTTP(redirectURL) ? "redirectingWeb" : "redirectingApp"); + + setSuccessRedirectURL( + await passkeyAuthenticationSuccessRedirectURL( + redirectURL, + passkeySessionID, + authorizationResponse, + ), + ); + }, []); + + /** (re)start the authentication flow */ + const authenticate = useCallback(async () => { + const cont = await authenticateBegin(); + if (cont) { + setContinuation(cont); + await authenticateContinue(cont); + } + }, [authenticateBegin, authenticateContinue]); + + useEffect(() => { + void authenticate(); + }, [authenticate]); + + useEffect(() => { + if (successRedirectURL) redirectToURL(successRedirectURL); + }, [successRedirectURL]); + + const handleVerify = () => void authenticateContinue(ensure(continuation)); + + const handleRetry = () => void authenticate(); + + const handleRecover = (() => { + const searchParams = new URLSearchParams(window.location.search); + const recover = nullToUndefined(searchParams.get("recover")); + if (!recover) { + // [Note: Conditional passkey recover option on accounts] + // + // Only show the recover option if the calling app provided us with + // the "recover" query parameter. For example, the mobile app does + // not pass it since it already shows a recovery option within the + // waiting screen that it shows. + return undefined; + } + + return () => redirectToPasskeyRecoverPage(new URL(recover)); + })(); + + const handleRedirectAgain = () => redirectToURL(ensure(successRedirectURL)); + + const components: Record = { + loading: , + unknownRedirect: , + webAuthnNotSupported: , + sessionAlreadyClaimed: , + unrecoverableFailure: , + failedDuringSignChallenge: ( + + ), + failed: ( + + ), + needUserFocus: , + waitingForUser: , + redirectingWeb: , + redirectingApp: , + }; + + return components[status]; +}; + +export default Page; + +// Not 100% accurate, but good enough for our purposes. +const isHTTP = (url: URL) => url.protocol.startsWith("http"); + +const redirectToURL = (url: URL) => { + log.info(`Redirecting to ${url.href}`); + window.location.href = url.href; +}; + +const Loading: React.FC = () => { + return ( + + + + ); +}; + +const UnknownRedirect: React.FC = () => { + return ; +}; + +const WebAuthnNotSupported: React.FC = () => { + return ; +}; + +const SessionAlreadyClaimed: React.FC = () => { + return ( + + + + + {t("passkey_login_already_claimed_session")} + + + + ); +}; + +const SessionAlreadyClaimed_ = styled("div")` + display: flex; + flex-direction: column; + align-items: center; + gap: 2rem; +`; + +const UnrecoverableFailure: React.FC = () => { + return ; +}; + +interface FailedProps { + message: string; +} + +const Failed: React.FC = ({ message }) => { + return ( + + + {t("passkey_login_failed")} + {message} + + ); +}; + +const Content: React.FC = ({ children }) => { + return ( + + {children} + + ); +}; + +const Content_ = styled("div")` + display: flex; + height: 100%; + justify-content: center; + align-items: center; +`; + +const ContentPaper = styled(Paper)` + width: 100%; + max-width: 24rem; + padding: 1rem; + + display: flex; + flex-direction: column; + gap: 1rem; +`; + +interface VerifyProps { + /** Called when the user presses the "Verify" button. */ + onVerify: () => void; +} + +/** + * Gain focus for the current page by requesting the user to explicitly click a + * button. For more details, see the documentation for `Continuation`. + */ +const Verify: React.FC = ({ onVerify }) => { + return ( + + + {t("passkey")} + + {t("passkey_verify_description")} + + + + {t("VERIFY")} + + + + ); +}; + +interface RetriableFailedProps { + /** + * Set this attribute to indicate that this failure occurred during the + * actual passkey verification (`navigator.credentials.get`). + * + * We customize the error message for such cases to give a hint to the user + * that they can try on their other devices too. + */ + duringSignChallenge?: boolean; + /** Callback invoked when the user presses the try again button. */ + onRetry: () => void; + /** + * Callback invoked when the user presses the button to recover their second + * factor, e.g. if they cannot login using it. + * + * This is optional. See [Note: Conditional passkey recover option on + * accounts]. + */ + onRecover: (() => void) | undefined; +} + +const RetriableFailed: React.FC = ({ + duringSignChallenge, + onRetry, + onRecover, +}) => { + return ( + + + {t("passkey_login_failed")} + + {duringSignChallenge + ? t("passkey_login_credential_hint") + : t("passkey_login_generic_error")} + + + + {t("try_again")} + + {onRecover && ( + + {t("RECOVER_TWO_FACTOR")} + + )} + + + ); +}; + +const ButtonStack = styled("div")` + display: flex; + flex-direction: column; + margin-block-start: 1rem; + gap: 1rem; +`; + +const WaitingForUser: React.FC = () => { + return ( + + + {t("passkey_login")} + + + {t("passkey_login_instructions")} + + + + + + ); +}; + +const WaitingImgContainer = styled("div")` + display: flex; + justify-content: center; + margin-block-start: 1rem; +`; + +const RedirectingWeb: React.FC = () => { + return ( + + + {t("passkey_verified")} + + {t("redirecting_back_to_app")} + + + ); +}; + +interface RedirectingAppProps { + /** Called when the user presses the button to redirect again */ + onRetry: () => void; +} + +const RedirectingApp: React.FC = ({ onRetry }) => { + return ( + + + {t("passkey_verified")} + + {t("redirecting_back_to_app")} + + + {t("redirect_close_instructions")} + + + + {t("redirect_again")} + + + + ); +}; diff --git a/web/apps/accounts/src/pages/recover.tsx b/web/apps/accounts/src/pages/recover.tsx deleted file mode 100644 index d825729e5e..0000000000 --- a/web/apps/accounts/src/pages/recover.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import Page_ from "@ente/accounts/pages/recover"; -import { useAppContext } from "./_app"; - -const Page = () => ; - -export default Page; diff --git a/web/apps/accounts/src/pages/signup.tsx b/web/apps/accounts/src/pages/signup.tsx deleted file mode 100644 index 403d3e7357..0000000000 --- a/web/apps/accounts/src/pages/signup.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import Page_ from "@ente/accounts/pages/signup"; -import { useAppContext } from "./_app"; - -const Page = () => ; - -export default Page; diff --git a/web/apps/accounts/src/pages/two-factor/setup.tsx b/web/apps/accounts/src/pages/two-factor/setup.tsx deleted file mode 100644 index 12716e2dfb..0000000000 --- a/web/apps/accounts/src/pages/two-factor/setup.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import Page_ from "@ente/accounts/pages/two-factor/setup"; -import { useAppContext } from "../_app"; - -const Page = () => ; - -export default Page; diff --git a/web/apps/accounts/src/pages/two-factor/verify.tsx b/web/apps/accounts/src/pages/two-factor/verify.tsx deleted file mode 100644 index 7c682b1b99..0000000000 --- a/web/apps/accounts/src/pages/two-factor/verify.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import Page_ from "@ente/accounts/pages/two-factor/verify"; -import { useAppContext } from "../_app"; - -const Page = () => ; - -export default Page; diff --git a/web/apps/accounts/src/pages/verify.tsx b/web/apps/accounts/src/pages/verify.tsx deleted file mode 100644 index bb2dc87788..0000000000 --- a/web/apps/accounts/src/pages/verify.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import Page_ from "@ente/accounts/pages/verify"; -import { useAppContext } from "./_app"; - -const Page = () => ; - -export default Page; diff --git a/web/apps/accounts/src/services/passkey.ts b/web/apps/accounts/src/services/passkey.ts new file mode 100644 index 0000000000..23dd6a44a6 --- /dev/null +++ b/web/apps/accounts/src/services/passkey.ts @@ -0,0 +1,579 @@ +import { isDevBuild } from "@/next/env"; +import { clientPackageName } from "@/next/types/app"; +import { TwoFactorAuthorizationResponse } from "@/next/types/credentials"; +import { ensure } from "@/utils/ensure"; +import { nullToUndefined } from "@/utils/transform"; +import { + fromB64URLSafeNoPadding, + toB64URLSafeNoPadding, + toB64URLSafeNoPaddingString, +} from "@ente/shared/crypto/internal/libsodium"; +import { apiOrigin } from "@ente/shared/network/api"; +import { z } from "zod"; + +/** Return true if the user's browser supports WebAuthn (Passkeys). */ +export const isWebAuthnSupported = () => !!navigator.credentials; + +/** + * Variant of {@link authenticatedRequestHeaders} but for authenticated requests + * made by the accounts app. + * + * @param token The accounts specific auth token to use for making API requests. + */ +const accountsAuthenticatedRequestHeaders = ( + token: string, +): Record => { + return { + "X-Auth-Token": token, + "X-Client-Package": clientPackageName("accounts"), + }; +}; + +const Passkey = z.object({ + /** A unique ID for the passkey */ + id: z.string(), + /** + * An arbitrary name associated by the user with the passkey (a.k.a + * its "friendly name"). + */ + friendlyName: z.string(), + /** + * Epoch milliseconds when this passkey was created. + */ + createdAt: z.number(), +}); + +export type Passkey = z.infer; + +const GetPasskeysResponse = z.object({ + passkeys: z.array(Passkey).nullish().transform(nullToUndefined), +}); + +/** + * Fetch the existing passkeys for the user. + * + * @param token The accounts specific auth token to use for making API requests. + * + * @returns An array of {@link Passkey}s. The array will be empty if the user + * has no passkeys. + */ +export const getPasskeys = async (token: string) => { + const url = `${apiOrigin()}/passkeys`; + const res = await fetch(url, { + headers: accountsAuthenticatedRequestHeaders(token), + }); + if (!res.ok) throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`); + const { passkeys } = GetPasskeysResponse.parse(await res.json()); + return passkeys ?? []; +}; + +/** + * Rename one of the user's existing passkey with the given {@link id}. + * + * @param token The accounts specific auth token to use for making API requests. + * + * @param id The `id` of the existing passkey to rename. + * + * @param name The new name (a.k.a. "friendly name"). + */ +export const renamePasskey = async ( + token: string, + id: string, + name: string, +) => { + const params = new URLSearchParams({ friendlyName: name }); + const url = `${apiOrigin()}/passkeys/${id}`; + const res = await fetch(`${url}?${params.toString()}`, { + method: "PATCH", + headers: accountsAuthenticatedRequestHeaders(token), + }); + if (!res.ok) throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`); +}; + +/** + * Delete one of the user's existing passkeys. + * + * @param token The accounts specific auth token to use for making API requests. + * + * @param id The `id` of the existing passkey to delete. + */ +export const deletePasskey = async (token: string, id: string) => { + const url = `${apiOrigin()}/passkeys/${id}`; + const res = await fetch(url, { + method: "DELETE", + headers: accountsAuthenticatedRequestHeaders(token), + }); + if (!res.ok) throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`); +}; + +/** + * Add a new passkey as the second factor to the user's account. + * + * @param token The accounts specific auth token to use for making API requests. + * + * @param name An arbitrary name that the user wishes to label this passkey with + * (a.k.a. "friendly name"). + */ +export const registerPasskey = async (token: string, name: string) => { + // Get options (and sessionID) from the backend. + const { sessionID, options } = await beginPasskeyRegistration(token); + + // Ask the browser to new (public key) credentials using these options. + const credential = ensure(await navigator.credentials.create(options)); + + // Finish by letting the backend know about these credentials so that it can + // save the public key for future authentication. + await finishPasskeyRegistration({ + token, + friendlyName: name, + sessionID, + credential, + }); +}; + +interface BeginPasskeyRegistrationResponse { + /** + * An identifier for this registration ceremony / session. + * + * This sessionID is subsequently passed to the API when finish credential + * creation to tie things together. + */ + sessionID: string; + /** + * Options that should be passed to `navigator.credential.create` when + * creating the new {@link Credential}. + */ + options: { + publicKey: PublicKeyCredentialCreationOptions; + }; +} + +const beginPasskeyRegistration = async (token: string) => { + const url = `${apiOrigin()}/passkeys/registration/begin`; + const res = await fetch(url, { + method: "POST", + headers: accountsAuthenticatedRequestHeaders(token), + }); + if (!res.ok) throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`); + + // [Note: Converting binary data in WebAuthn API payloads] + // + // The server returns a JSON containing a "sessionID" (to tie together the + // beginning and the end of the registration), and "options" that we should + // pass on to the browser when asking it to create credentials. + // + // However, some massaging needs to be done first. On the backend, we use + // the [go-webauthn](https://github.com/go-webauthn/webauthn) library to + // begin the registration ceremony, and we verbatim credential creation + // options that the library returns to us. These are meant to plug directly + // into `CredentialCreationOptions` that `navigator.credential.create` + // expects. Specifically, since we're creating a public key credential, the + // `publicKey` attribute of the returned options will be in the shape of the + // `PublicKeyCredentialCreationOptions` expected by the browser). Except, + // binary data. + // + // Binary data in the returned `PublicKeyCredentialCreationOptions` are + // serialized as a "URLEncodedBase64", which is a URL-encoded Base64 string + // without any padding. The library is following the WebAuthn recommendation + // when it does this: + // + // > The term "Base64url Encoding refers" to the base64 encoding using the + // > URL- and filename-safe character set defined in Section 5 of RFC4648, + // > which all trailing '=' characters omitted (as permitted by Section 3.2) + // > + // > https://www.w3.org/TR/webauthn-3/#base64url-encoding + // + // However, the browser expects binary data as an "ArrayBuffer, TypedArray + // or DataView". + // https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredentialCreationOptions + // + // So we do the conversion here. + // + // 1. To avoid inventing an intermediary type and the boilerplate that'd + // come with it, we do a force typecast the options in the response to + // one that has `PublicKeyCredentialCreationOptions`. + // + // 2. Convert the two binary data fields that are expected to be in the + // response from URLEncodedBase64 strings to Uint8Arrays. There is a + // third possibility, excludedCredentials[].id, but that we don't + // currently use. + // + // The web.dev guide calls this out too: + // + // > ArrayBuffer values transferred from the server such as `challenge`, + // > `user.id` and credential `id` for `excludeCredentials` need to be + // > encoded on transmission. Don't forget to decode them on the frontend + // > before passing to the WebAuthn API call. We recommend using Base64URL + // > encode. + // > + // > https://web.dev/articles/passkey-registration + // + // So that's that. But to further complicate things, the libdom.ts typings + // included with the current TypeScript version (5.4) indicate these binary + // types as a: + // + // type BufferSource = ArrayBufferView | ArrayBuffer + // + // However MDN documentation states that they can be TypedArrays (e.g. + // Uint8Arrays), and using Uint8Arrays works in practice too. So another + // force cast is needed. + // + // ---- + // + // Finally, the same process needs to happen, in reverse, when we're sending + // the browser's response to credential creation to our backend for storing + // that credential (for future authentication). Binary fields need to be + // converted to URL-safe B64 before transmission. + + const { sessionID, options } = + (await res.json()) as BeginPasskeyRegistrationResponse; + + options.publicKey.challenge = await serverB64ToBinary( + options.publicKey.challenge, + ); + + options.publicKey.user.id = await serverB64ToBinary( + options.publicKey.user.id, + ); + + return { sessionID, options }; +}; + +/** + * This is the function that does the dirty work for the binary conversion, + * including the unfortunate typecasts. + * + * See: [Note: Converting binary data in WebAuthn API payloads] + */ +const serverB64ToBinary = async (b: BufferSource) => { + // This is actually a URL-safe B64 string without trailing padding. + const b64String = b as unknown as string; + // Convert it to a Uint8Array by doing the appropriate B64 decoding. + const bytes = await fromB64URLSafeNoPadding(b64String); + // Cast again to satisfy the incomplete BufferSource type. + return bytes as unknown as BufferSource; +}; + +/** + * This is the sibling of {@link serverB64ToBinary} that does the conversions in + * the other direction. + * + * See: [Note: Converting binary data in WebAuthn API payloads] + */ +const binaryToServerB64 = async (b: ArrayBuffer) => { + // Convert it to a Uint8Array + const bytes = new Uint8Array(b); + // Convert to a URL-safe B64 string without any trailing padding. + const b64String = await toB64URLSafeNoPadding(bytes); + // Lie about the types to make the compiler happy. + return b64String as unknown as BufferSource; +}; + +interface FinishPasskeyRegistrationOptions { + token: string; + sessionID: string; + friendlyName: string; + credential: Credential; +} + +const finishPasskeyRegistration = async ({ + token, + sessionID, + friendlyName, + credential, +}: FinishPasskeyRegistrationOptions) => { + const attestationResponse = authenticatorAttestationResponse(credential); + + const attestationObject = await binaryToServerB64( + attestationResponse.attestationObject, + ); + const clientDataJSON = await binaryToServerB64( + attestationResponse.clientDataJSON, + ); + const transports = attestationResponse.getTransports(); + + const params = new URLSearchParams({ friendlyName, sessionID }); + const url = `${apiOrigin()}/passkeys/registration/finish`; + const res = await fetch(`${url}?${params.toString()}`, { + method: "POST", + headers: accountsAuthenticatedRequestHeaders(token), + body: JSON.stringify({ + id: credential.id, + // This is meant to be the ArrayBuffer version of the (base64 + // encoded) `id`, but since we then would need to base64 encode it + // anyways for transmission, we can just reuse the same string. + rawId: credential.id, + type: credential.type, + response: { + attestationObject, + clientDataJSON, + transports, + }, + }), + }); + if (!res.ok) throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`); +}; + +/** + * A function to hide the type casts necessary to extract an + * {@link AuthenticatorAttestationResponse} from the {@link Credential} we + * obtain during a new passkey registration. + */ +const authenticatorAttestationResponse = (credential: Credential) => { + // We passed `options: { publicKey }` to `navigator.credentials.create`, and + // so we will get back an `PublicKeyCredential`: + // https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredentialCreationOptions#creating_a_public_key_credential + // + // However, the return type of `create` is the base `Credential`, so we need + // to cast. + const pkCredential = credential as PublicKeyCredential; + + // Further, since this was a `create` and not a `get`, the + // PublicKeyCredential.response will be an + // `AuthenticatorAttestationResponse` (See same MDN reference). + // + // We need to cast again. + const attestationResponse = + pkCredential.response as AuthenticatorAttestationResponse; + + return attestationResponse; +}; + +/** + * Return `true` if the given {@link redirectURL} (obtained from the redirect + * query parameter passed around during the passkey verification flow) is one of + * the whitelisted URLs that we allow redirecting to on success. + */ +export const isWhitelistedRedirect = (redirectURL: URL) => + (isDevBuild && redirectURL.hostname.endsWith("localhost")) || + redirectURL.host.endsWith(".ente.io") || + redirectURL.host.endsWith(".ente.sh") || + redirectURL.protocol == "ente:" || + redirectURL.protocol == "enteauth:"; + +export interface BeginPasskeyAuthenticationResponse { + /** + * An identifier for this authentication ceremony / session. + * + * This `ceremonySessionID` is subsequently passed to the API when finish + * credential creation to tie things together. + */ + ceremonySessionID: string; + /** + * Options that should be passed to `navigator.credential.get` to obtain the + * attested {@link Credential}. + */ + options: { + publicKey: PublicKeyCredentialRequestOptions; + }; +} + +/** + * The passkey session which we are trying to start an authentication ceremony + * for has already finished elsewhere. + */ +export const passkeySessionAlreadyClaimedErrorMessage = + "Passkey session already claimed"; + +/** + * Create a authentication ceremony session and return a challenge and a list of + * public key credentials that can be used to attest that challenge. + * + * [Note: WebAuthn authentication flow] + * + * This is step 1 of passkey authentication flow as described in + * https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API#authenticating_a_user + * + * @param passkeySessionID A session created by the requesting app that can be + * used to initiate a passkey authentication ceremony on the accounts app. + * + * @throws In addition to arbitrary errors, it throws errors with the message + * {@link passkeySessionAlreadyClaimedErrorMessage}. + */ +export const beginPasskeyAuthentication = async ( + passkeySessionID: string, +): Promise => { + const url = `${apiOrigin()}/users/two-factor/passkeys/begin`; + const res = await fetch(url, { + method: "POST", + body: JSON.stringify({ sessionID: passkeySessionID }), + }); + if (!res.ok) { + if (res.status == 409) + throw new Error(passkeySessionAlreadyClaimedErrorMessage); + throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`); + } + + // See: [Note: Converting binary data in WebAuthn API payloads] + + const { ceremonySessionID, options } = + (await res.json()) as BeginPasskeyAuthenticationResponse; + + options.publicKey.challenge = await serverB64ToBinary( + options.publicKey.challenge, + ); + + for (const credential of options.publicKey.allowCredentials ?? []) { + credential.id = await serverB64ToBinary(credential.id); + } + + return { ceremonySessionID, options }; +}; + +/** + * Authenticate the user by asking them to use a passkey that the they had + * previously created for the current domain to sign a challenge. + * + * This function implements steps 2 and 3 of the passkey authentication flow. + * See [Note: WebAuthn authentication flow]. + * + * @param publicKey A challenge and a list of public key credentials + * ("passkeys") that can be used to attest that challenge. + * + * @returns A {@link PublicKeyCredential} that contains the signed + * {@link AuthenticatorAssertionResponse}. Note that the type does not reflect + * this specialization, and the result is a base {@link Credential}. + */ +export const signChallenge = async ( + publicKey: PublicKeyCredentialRequestOptions, +) => nullToUndefined(await navigator.credentials.get({ publicKey })); + +interface FinishPasskeyAuthenticationOptions { + passkeySessionID: string; + ceremonySessionID: string; + /** + * The package name of the client on whose behalf we're authenticating with + * the user's passkey. + * + * This is used by the backend to generate an appropriately scoped auth + * token for used by (and only by) the authenticating app. + */ + clientPackage: string; + credential: Credential; +} + +/** + * Finish the authentication by providing the signed assertion to the backend. + * + * This function implements steps 4 and 5 of the passkey authentication flow. + * See [Note: WebAuthn authentication flow]. + * + * @returns The result of successful authentication, a + * {@link TwoFactorAuthorizationResponse}. + */ +export const finishPasskeyAuthentication = async ({ + passkeySessionID, + ceremonySessionID, + clientPackage, + credential, +}: FinishPasskeyAuthenticationOptions) => { + const response = authenticatorAssertionResponse(credential); + + const authenticatorData = await binaryToServerB64( + response.authenticatorData, + ); + const clientDataJSON = await binaryToServerB64(response.clientDataJSON); + const signature = await binaryToServerB64(response.signature); + const userHandle = response.userHandle + ? await binaryToServerB64(response.userHandle) + : null; + + const params = new URLSearchParams({ + sessionID: passkeySessionID, + ceremonySessionID, + clientPackage, + }); + const url = `${apiOrigin()}/users/two-factor/passkeys/finish`; + const res = await fetch(`${url}?${params.toString()}`, { + method: "POST", + headers: { + "X-Client-Package": clientPackage, + }, + body: JSON.stringify({ + id: credential.id, + // This is meant to be the ArrayBuffer version of the (base64 + // encoded) `id`, but since we then would need to base64 encode it + // anyways for transmission, we can just reuse the same string. + rawId: credential.id, + type: credential.type, + response: { + authenticatorData, + clientDataJSON, + signature, + userHandle, + }, + }), + }); + if (!res.ok) throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`); + + return TwoFactorAuthorizationResponse.parse(await res.json()); +}; + +/** + * A function to hide the type casts necessary to extract a + * {@link AuthenticatorAssertionResponse} from the {@link Credential} we obtain + * during a passkey attestation. + */ +const authenticatorAssertionResponse = (credential: Credential) => { + // We passed `options: { publicKey }` to `navigator.credentials.get`, and so + // we will get back an `PublicKeyCredential`: + // https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/get#web_authentication_api + // + // However, the return type of `get` is the base `Credential`, so we need to + // cast. + const pkCredential = credential as PublicKeyCredential; + + // Further, since this was a `get` and not a `create`, the + // PublicKeyCredential.response will be an `AuthenticatorAssertionResponse` + // (See same MDN reference). + // + // We need to cast again. + const assertionResponse = + pkCredential.response as AuthenticatorAssertionResponse; + + return assertionResponse; +}; + +/** + * Create a redirection URL to get back to the calling app that initiated the + * passkey authentication flow with the result of the authentication. + * + * @param redirectURL The base URL to redirect to. Provided by the calling app + * that initiated the passkey authentication. + * + * @param passkeySessionID The passkeySessionID that was provided by the calling + * app that initiated the passkey authentication. It is returned back in the + * response so that the calling app has a way to ensure that this is indeed a + * redirect for the session that they initiated and are waiting for. + * + * @param twoFactorAuthorizationResponse The result of + * {@link finishPasskeyAuthentication} returned by the backend. + */ +export const passkeyAuthenticationSuccessRedirectURL = async ( + redirectURL: URL, + passkeySessionID: string, + twoFactorAuthorizationResponse: TwoFactorAuthorizationResponse, +) => { + const encodedResponse = await toB64URLSafeNoPaddingString( + JSON.stringify(twoFactorAuthorizationResponse), + ); + redirectURL.searchParams.set("passkeySessionID", passkeySessionID); + redirectURL.searchParams.set("response", encodedResponse); + return redirectURL; +}; + +/** + * Redirect back to the app that initiated the passkey authentication, + * navigating the user to a place where they can reset their second factor using + * their recovery key (e.g. if they have lost access to their passkey). + * + * The same considerations mentioned in [Note: Finish passkey flow in the + * requesting app] apply to recovery too, which is why we need to redirect back + * to the app on whose behalf we're authenticating. + * + * @param recoverURL The recovery URL provided as a query parameter by the app + * that called us. + */ +export const redirectToPasskeyRecoverPage = (recoverURL: URL) => { + window.location.href = recoverURL.href; +}; diff --git a/web/apps/accounts/src/services/passkeysService.ts b/web/apps/accounts/src/services/passkeysService.ts deleted file mode 100644 index 27f9773e5c..0000000000 --- a/web/apps/accounts/src/services/passkeysService.ts +++ /dev/null @@ -1,201 +0,0 @@ -import log from "@/next/log"; -import HTTPService from "@ente/shared/network/HTTPService"; -import { getEndpoint } from "@ente/shared/network/api"; -import { getToken } from "@ente/shared/storage/localStorage/helpers"; -import _sodium from "libsodium-wrappers"; - -const ENDPOINT = getEndpoint(); - -export const getPasskeys = async () => { - try { - const token = getToken(); - if (!token) return; - const response = await HTTPService.get( - `${ENDPOINT}/passkeys`, - {}, - { "X-Auth-Token": token }, - ); - return await response.data; - } catch (e) { - log.error("get passkeys failed", e); - throw e; - } -}; - -export const renamePasskey = async (id: string, name: string) => { - try { - const token = getToken(); - if (!token) return; - const response = await HTTPService.patch( - `${ENDPOINT}/passkeys/${id}`, - {}, - { friendlyName: name }, - { "X-Auth-Token": token }, - ); - return await response.data; - } catch (e) { - log.error("rename passkey failed", e); - throw e; - } -}; - -export const deletePasskey = async (id: string) => { - try { - const token = getToken(); - if (!token) return; - const response = await HTTPService.delete( - `${ENDPOINT}/passkeys/${id}`, - {}, - {}, - { "X-Auth-Token": token }, - ); - return await response.data; - } catch (e) { - log.error("delete passkey failed", e); - throw e; - } -}; - -export const getPasskeyRegistrationOptions = async () => { - try { - const token = getToken(); - if (!token) return; - const response = await HTTPService.get( - `${ENDPOINT}/passkeys/registration/begin`, - {}, - { - "X-Auth-Token": token, - }, - ); - return await response.data; - } catch (e) { - log.error("get passkey registration options failed", e); - throw e; - } -}; - -export const finishPasskeyRegistration = async ( - friendlyName: string, - credential: Credential, - sessionId: string, -) => { - try { - const attestationObjectB64 = _sodium.to_base64( - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - new Uint8Array(credential.response.attestationObject), - _sodium.base64_variants.URLSAFE_NO_PADDING, - ); - const clientDataJSONB64 = _sodium.to_base64( - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - new Uint8Array(credential.response.clientDataJSON), - _sodium.base64_variants.URLSAFE_NO_PADDING, - ); - - const token = getToken(); - if (!token) return; - - const response = await HTTPService.post( - `${ENDPOINT}/passkeys/registration/finish`, - JSON.stringify({ - id: credential.id, - rawId: credential.id, - type: credential.type, - response: { - attestationObject: attestationObjectB64, - clientDataJSON: clientDataJSONB64, - }, - }), - { - friendlyName, - sessionID: sessionId, - }, - { - "X-Auth-Token": token, - }, - ); - return await response.data; - } catch (e) { - log.error("finish passkey registration failed", e); - throw e; - } -}; - -export interface BeginPasskeyAuthenticationResponse { - ceremonySessionID: string; - options: Options; -} -interface Options { - publicKey: PublicKeyCredentialRequestOptions; -} - -export const beginPasskeyAuthentication = async ( - sessionId: string, -): Promise => { - try { - const data = await HTTPService.post( - `${ENDPOINT}/users/two-factor/passkeys/begin`, - { - sessionID: sessionId, - }, - ); - - return data.data; - } catch (e) { - log.error("begin passkey authentication failed", e); - throw e; - } -}; - -export const finishPasskeyAuthentication = async ( - credential: Credential, - sessionId: string, - ceremonySessionId: string, -) => { - try { - const data = await HTTPService.post( - `${ENDPOINT}/users/two-factor/passkeys/finish`, - { - id: credential.id, - rawId: credential.id, - type: credential.type, - response: { - authenticatorData: _sodium.to_base64( - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - new Uint8Array(credential.response.authenticatorData), - _sodium.base64_variants.URLSAFE_NO_PADDING, - ), - clientDataJSON: _sodium.to_base64( - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - new Uint8Array(credential.response.clientDataJSON), - _sodium.base64_variants.URLSAFE_NO_PADDING, - ), - signature: _sodium.to_base64( - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - new Uint8Array(credential.response.signature), - _sodium.base64_variants.URLSAFE_NO_PADDING, - ), - userHandle: _sodium.to_base64( - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - new Uint8Array(credential.response.userHandle), - _sodium.base64_variants.URLSAFE_NO_PADDING, - ), - }, - }, - { - sessionID: sessionId, - ceremonySessionID: ceremonySessionId, - }, - ); - - return data.data; - } catch (e) { - log.error("finish passkey authentication failed", e); - throw e; - } -}; diff --git a/web/apps/accounts/src/styles/global.css b/web/apps/accounts/src/styles/global.css index 98ad85a9b3..95a483b907 100644 --- a/web/apps/accounts/src/styles/global.css +++ b/web/apps/accounts/src/styles/global.css @@ -54,102 +54,6 @@ body { height: 100vh; } -.pswp__button--custom { - width: 48px; - height: 48px; - background: none !important; - background-image: none !important; - color: #fff; -} - -.pswp__item video { - width: 100%; - height: 100%; -} - -.pswp-item-container { - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - object-fit: contain; -} - -.pswp-item-container > * { - position: absolute; - transition: opacity 1s ease; - max-width: 100%; - max-height: 100%; -} - -.pswp-item-container > img { - opacity: 1; -} - -.pswp-item-container > video { - opacity: 0; -} - -.pswp-item-container > div.download-banner { - width: 100%; - height: 16vh; - padding: 2vh 0; - background-color: #151414; - color: #ddd; - display: flex; - flex-direction: column; - align-items: center; - justify-content: space-around; - opacity: 0.8; - font-size: 20px; -} - -.download-banner > a { - width: 130px; -} - -.pswp__img { - object-fit: contain; -} - -.pswp__button--arrow--left, -.pswp__button--arrow--right { - color: #fff; - background-color: #333333 !important; - border-radius: 50%; - width: 56px; - height: 56px; -} -.pswp__button--arrow--left::before, -.pswp__button--arrow--right::before { - background: none !important; -} - -.pswp__button--arrow--left { - margin-left: 20px; -} - -.pswp__button--arrow--right { - margin-right: 20px; -} - -.pswp-custom-caption-container { - width: 100%; - display: flex; - justify-content: flex-end; - bottom: 56px; - background-color: transparent !important; -} - -.pswp__caption--empty { - display: none; -} - -.bg-upload-progress-bar { - background-color: #51cd7c; -} - div.otp-input input { width: 36px !important; height: 36px; @@ -168,18 +72,3 @@ div.otp-input input:focus { transition: 0.5s; outline: none; } - -.flash-message { - padding: 16px; - display: flex; - align-items: center; -} - -@-webkit-keyframes rotation { - from { - -webkit-transform: rotate(0deg); - } - to { - -webkit-transform: rotate(359deg); - } -} diff --git a/web/apps/accounts/src/types/passkey.ts b/web/apps/accounts/src/types/passkey.ts deleted file mode 100644 index 3d05b5b91f..0000000000 --- a/web/apps/accounts/src/types/passkey.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface Passkey { - id: string; - userID: number; - friendlyName: string; - createdAt: number; -} diff --git a/web/apps/accounts/tsconfig.json b/web/apps/accounts/tsconfig.json index cbdd32f742..bbe7217aad 100644 --- a/web/apps/accounts/tsconfig.json +++ b/web/apps/accounts/tsconfig.json @@ -1,25 +1,17 @@ { - "extends": "../../tsconfig.base.json", + "extends": "@/build-config/tsconfig-next.json", "compilerOptions": { + /* Set the base directory from which to resolve bare module names */ "baseUrl": "./src", - "downlevelIteration": true, - "jsx": "preserve", - "jsxImportSource": "@emotion/react", - "lib": ["dom", "dom.iterable", "esnext", "webworker"], - "noImplicitAny": false, - "noUnusedLocals": false, - "noUnusedParameters": false, - "strictNullChecks": false, - "target": "es5", - "useUnknownInCatchVariables": false + + /* TODO(MR): Enable this */ + "noUncheckedIndexedAccess": false, + /* MUI doesn't play great with exactOptionalPropertyTypes currently. */ + "exactOptionalPropertyTypes": false }, "include": [ - "next-env.d.ts", - "**/*.ts", - "**/*.tsx", - "**/*.js", - "../../packages/shared/themes/mui-theme.d.ts", - "../../packages/accounts/**/*.tsx" - ], - "exclude": ["node_modules", "out", ".next", "thirdparty"] + "src", + "../../packages/next/global-electron.d.ts", + "../../packages/shared/themes/mui-theme.d.ts" + ] } diff --git a/web/apps/auth/src/pages/_app.tsx b/web/apps/auth/src/pages/_app.tsx index 0ada75c3fc..a46f2f867c 100644 --- a/web/apps/auth/src/pages/_app.tsx +++ b/web/apps/auth/src/pages/_app.tsx @@ -1,17 +1,18 @@ import { CustomHead } from "@/next/components/Head"; +import { setAppNameForAuthenticatedRequests } from "@/next/http"; import { setupI18n } from "@/next/i18n"; import { logStartupBanner, logUnhandledErrorsAndRejections, } from "@/next/log-web"; -import type { AppName, BaseAppContextT } from "@/next/types/app"; +import { + appTitle, + clientPackageName, + type AppName, + type BaseAppContextT, +} from "@/next/types/app"; import { ensure } from "@/utils/ensure"; import { accountLogout } from "@ente/accounts/services/logout"; -import { - APPS, - APP_TITLES, - CLIENT_PACKAGE_NAMES, -} from "@ente/shared/apps/constants"; import { Overlay } from "@ente/shared/components/Container"; import DialogBoxV2 from "@ente/shared/components/DialogBoxV2"; import type { DialogBoxAttributesV2 } from "@ente/shared/components/DialogBoxV2/types"; @@ -30,7 +31,13 @@ import { ThemeProvider } from "@mui/material/styles"; import { t } from "i18next"; import type { AppProps } from "next/app"; import { useRouter } from "next/router"; -import { createContext, useContext, useEffect, useRef, useState } from "react"; +import React, { + createContext, + useContext, + useEffect, + useRef, + useState, +} from "react"; import LoadingBar, { type LoadingBarRef } from "react-top-loading-bar"; import "../../public/css/global.css"; @@ -51,7 +58,7 @@ export const AppContext = createContext(undefined); /** Utility hook to reduce amount of boilerplate in account related pages. */ export const useAppContext = () => ensure(useContext(AppContext)); -export default function App({ Component, pageProps }: AppProps) { +const App: React.FC = ({ Component, pageProps }) => { const appName: AppName = "auth"; const router = useRouter(); @@ -67,19 +74,20 @@ export default function App({ Component, pageProps }: AppProps) { DialogBoxAttributesV2 | undefined >(); const [dialogBoxV2View, setDialogBoxV2View] = useState(false); - const isMobile = useMediaQuery("(max-width:428px)"); + const isMobile = useMediaQuery("(max-width: 428px)"); const [themeColor, setThemeColor] = useLocalState( LS_KEYS.THEME, THEME_COLOR.DARK, ); useEffect(() => { - setupI18n().finally(() => setIsI18nReady(true)); + void setupI18n().finally(() => setIsI18nReady(true)); const userId = (getData(LS_KEYS.USER) as User)?.id; - logStartupBanner(APPS.AUTH, userId); + logStartupBanner(appName, userId); logUnhandledErrorsAndRejections(true); + setAppNameForAuthenticatedRequests(appName); HTTPService.setHeaders({ - "X-Client-Package": CLIENT_PACKAGE_NAMES.get(APPS.AUTH), + "X-Client-Package": clientPackageName(appName), }); return () => logUnhandledErrorsAndRejections(false); }, []); @@ -151,16 +159,15 @@ export default function App({ Component, pageProps }: AppProps) { somethingWentWrong, }; - // TODO: Refactor this to have a fallback const title = isI18nReady ? t("title", { context: "auth" }) - : APP_TITLES.get(APPS.AUTH) ?? ""; + : appTitle[appName]; return ( <> - + {showNavbar && } @@ -197,4 +204,6 @@ export default function App({ Component, pageProps }: AppProps) { ); -} +}; + +export default App; diff --git a/web/apps/auth/src/pages/auth.tsx b/web/apps/auth/src/pages/auth.tsx index 9c49ffbed3..7f40226e30 100644 --- a/web/apps/auth/src/pages/auth.tsx +++ b/web/apps/auth/src/pages/auth.tsx @@ -3,9 +3,9 @@ import { HorizontalFlex, VerticallyCentered, } from "@ente/shared/components/Container"; -import type { DialogBoxAttributesV2 } from "@ente/shared/components/DialogBoxV2/types"; import { EnteLogo } from "@ente/shared/components/EnteLogo"; import EnteSpinner from "@ente/shared/components/EnteSpinner"; +import { sessionExpiredDialogAttributes } from "@ente/shared/components/LoginComponents"; import NavbarBase from "@ente/shared/components/Navbar/base"; import OverflowMenu from "@ente/shared/components/OverflowMenu/menu"; import { OverflowMenuOption } from "@ente/shared/components/OverflowMenu/option"; @@ -140,19 +140,6 @@ const Page: React.FC = () => { export default Page; -const sessionExpiredDialogAttributes = ( - action: () => void, -): DialogBoxAttributesV2 => ({ - title: t("SESSION_EXPIRED"), - content: t("SESSION_EXPIRED_MESSAGE"), - nonClosable: true, - proceed: { - text: t("LOGIN"), - action, - variant: "accent", - }, -}); - const AuthNavbar: React.FC = () => { const { isMobile, logout } = ensure(useContext(AppContext)); diff --git a/web/apps/auth/src/pages/passkeys/finish.tsx b/web/apps/auth/src/pages/passkeys/finish.tsx index 866dcf9e3a..17f8e47eb4 100644 --- a/web/apps/auth/src/pages/passkeys/finish.tsx +++ b/web/apps/auth/src/pages/passkeys/finish.tsx @@ -1,3 +1,6 @@ -import Page from "@ente/accounts/pages/passkeys/finish"; +import Page_ from "@ente/accounts/pages/passkeys/finish"; +import { useAppContext } from "../_app"; + +const Page = () => ; export default Page; diff --git a/web/apps/accounts/src/pages/two-factor/recover.tsx b/web/apps/auth/src/pages/passkeys/recover.tsx similarity index 57% rename from web/apps/accounts/src/pages/two-factor/recover.tsx rename to web/apps/auth/src/pages/passkeys/recover.tsx index d3f40be49c..5bc2230c87 100644 --- a/web/apps/accounts/src/pages/two-factor/recover.tsx +++ b/web/apps/auth/src/pages/passkeys/recover.tsx @@ -1,6 +1,8 @@ import Page_ from "@ente/accounts/pages/two-factor/recover"; import { useAppContext } from "../_app"; -const Page = () => ; +const Page = () => ( + +); export default Page; diff --git a/web/apps/auth/src/pages/two-factor/recover.tsx b/web/apps/auth/src/pages/two-factor/recover.tsx index d3f40be49c..61414077e5 100644 --- a/web/apps/auth/src/pages/two-factor/recover.tsx +++ b/web/apps/auth/src/pages/two-factor/recover.tsx @@ -1,6 +1,6 @@ import Page_ from "@ente/accounts/pages/two-factor/recover"; import { useAppContext } from "../_app"; -const Page = () => ; +const Page = () => ; export default Page; diff --git a/web/apps/auth/tsconfig.json b/web/apps/auth/tsconfig.json index 507ae19bdf..685ff12c59 100644 --- a/web/apps/auth/tsconfig.json +++ b/web/apps/auth/tsconfig.json @@ -1,11 +1,5 @@ { "extends": "@/build-config/tsconfig-next.json", - "include": [ - "src", - "next-env.d.ts", - "../../packages/next/global-electron.d.ts", - "../../packages/shared/themes/mui-theme.d.ts" - ], "compilerOptions": { /* Set the base directory from which to resolve bare module names */ "baseUrl": "./src", @@ -16,5 +10,11 @@ "noUncheckedIndexedAccess": false, /* MUI doesn't play great with exactOptionalPropertyTypes currently. */ "exactOptionalPropertyTypes": false - } + }, + "include": [ + "src", + "next-env.d.ts", + "../../packages/next/global-electron.d.ts", + "../../packages/shared/themes/mui-theme.d.ts" + ] } diff --git a/web/apps/cast/src/pages/_app.tsx b/web/apps/cast/src/pages/_app.tsx index d85ac05422..6428bed552 100644 --- a/web/apps/cast/src/pages/_app.tsx +++ b/web/apps/cast/src/pages/_app.tsx @@ -1,7 +1,7 @@ import { CustomHead } from "@/next/components/Head"; import { disableDiskLogs } from "@/next/log"; import { logUnhandledErrorsAndRejections } from "@/next/log-web"; -import { APPS, APP_TITLES } from "@ente/shared/apps/constants"; +import { appTitle } from "@/next/types/app"; import { getTheme } from "@ente/shared/themes"; import { THEME_COLOR } from "@ente/shared/themes/constants"; import { CssBaseline, ThemeProvider } from "@mui/material"; @@ -19,9 +19,9 @@ export default function App({ Component, pageProps }: AppProps) { return ( <> - + - + diff --git a/web/apps/cast/src/services/pair.ts b/web/apps/cast/src/services/pair.ts index 36b54cf759..9edea9ca4f 100644 --- a/web/apps/cast/src/services/pair.ts +++ b/web/apps/cast/src/services/pair.ts @@ -1,8 +1,10 @@ import log from "@/next/log"; import { wait } from "@/utils/promise"; -import { boxSealOpen, toB64 } from "@ente/shared/crypto/internal/libsodium"; +import { + boxSealOpen, + generateKeyPair, +} from "@ente/shared/crypto/internal/libsodium"; import castGateway from "@ente/shared/network/cast"; -import _sodium from "libsodium-wrappers"; export interface Registration { /** A pairing code shown on the screen. A client can use this to connect. */ @@ -75,9 +77,8 @@ export interface Registration { */ export const register = async (): Promise => { // Generate keypair. - const keypair = await generateKeyPair(); - const publicKeyB64 = await toB64(keypair.publicKey); - const privateKeyB64 = await toB64(keypair.privateKey); + const { publicKey: publicKeyB64, privateKey: privateKeyB64 } = + await generateKeyPair(); // Register keypair with museum to get a pairing code. let pairingCode: string; @@ -127,8 +128,3 @@ export const getCastData = async (registration: Registration) => { return JSON.parse(atob(decryptedCastData)); }; - -const generateKeyPair = async () => { - await _sodium.ready; - return _sodium.crypto_box_keypair(); -}; diff --git a/web/apps/cast/src/services/render.ts b/web/apps/cast/src/services/render.ts index 79065c2afc..47e983ab1d 100644 --- a/web/apps/cast/src/services/render.ts +++ b/web/apps/cast/src/services/render.ts @@ -11,11 +11,7 @@ import { wait } from "@/utils/promise"; import ComlinkCryptoWorker from "@ente/shared/crypto"; import { ApiError } from "@ente/shared/error"; import HTTPService from "@ente/shared/network/HTTPService"; -import { - getCastFileURL, - getCastThumbnailURL, - getEndpoint, -} from "@ente/shared/network/api"; +import { apiOrigin, customAPIOrigin } from "@ente/shared/network/api"; import type { AxiosResponse } from "axios"; import type { CastData } from "services/cast-data"; import { detectMediaMIMEType } from "services/detect-type"; @@ -168,7 +164,7 @@ const getEncryptedCollectionFiles = async ( let resp: AxiosResponse; do { resp = await HTTPService.get( - `${getEndpoint()}/cast/diff`, + `${apiOrigin()}/cast/diff`, { sinceTime }, { "Cache-Control": "no-cache", @@ -325,22 +321,36 @@ const downloadFile = async ( if (!isImageOrLivePhoto(file)) throw new Error("Can only cast images and live photos"); - const url = shouldUseThumbnail - ? getCastThumbnailURL(file.id) - : getCastFileURL(file.id); - const resp = await HTTPService.get( - url, - null, - { - "X-Cast-Access-Token": castToken, - }, - { responseType: "arraybuffer" }, - ); - if (resp.data === undefined) throw new Error(`Failed to get ${url}`); + const getFile = () => { + const customOrigin = customAPIOrigin(); + if (customOrigin) { + // See: [Note: Passing credentials for self-hosted file fetches] + const params = new URLSearchParams({ castToken }); + const baseURL = shouldUseThumbnail + ? `${customOrigin}/cast/files/preview/${file.id}` + : `${customOrigin}/cast/files/download/${file.id}`; + return fetch(`${baseURL}?${params.toString()}`); + } else { + const url = shouldUseThumbnail + ? `https://cast-albums.ente.io/preview/?fileID=${file.id}` + : `https://cast-albums.ente.io/download/?fileID=${file.id}`; + return fetch(url, { + headers: { + "X-Cast-Access-Token": castToken, + }, + }); + } + }; + + const res = await getFile(); + if (!res.ok) + throw new Error( + `Failed to fetch file with ID ${file.id}: HTTP ${res.status}`, + ); const cryptoWorker = await ComlinkCryptoWorker.getInstance(); const decrypted = await cryptoWorker.decryptFile( - new Uint8Array(resp.data), + new Uint8Array(await res.arrayBuffer()), await cryptoWorker.fromB64( shouldUseThumbnail ? file.thumbnail.decryptionHeader diff --git a/web/apps/payments/README.md b/web/apps/payments/README.md index ebf3a63901..4b0f99402a 100644 --- a/web/apps/payments/README.md +++ b/web/apps/payments/README.md @@ -20,7 +20,7 @@ Add the following to `web/apps/photos/.env.local`: ```env NEXT_PUBLIC_ENTE_ENDPOINT = http://localhost:8080 -NEXT_PUBLIC_ENTE_PAYMENTS_ENDPOINT = http://localhost:3001 +NEXT_PUBLIC_ENTE_PAYMENTS_URL = http://localhost:3001 ``` Then start it locally diff --git a/web/apps/photos/.env b/web/apps/photos/.env index 978c677769..fe626d52d6 100644 --- a/web/apps/photos/.env +++ b/web/apps/photos/.env @@ -41,13 +41,13 @@ # # NEXT_PUBLIC_ENTE_ENDPOINT = http://localhost:3000 -# The Ente API endpoint for accounts related functionality +# The URL of the accounts app # -# NEXT_PUBLIC_ENTE_ACCOUNTS_ENDPOINT = http://localhost:3001 +# NEXT_PUBLIC_ENTE_ACCOUNTS_URL = http://localhost:3001 -# The Ente API endpoint for payments related functionality +# The URL of the payments app # -# NEXT_PUBLIC_ENTE_PAYMENTS_ENDPOINT = http://localhost:3001 +# NEXT_PUBLIC_ENTE_PAYMENTS_URL = http://localhost:3001 # The URL for the shared albums deployment # @@ -69,7 +69,7 @@ # # NEXT_PUBLIC_ENTE_ALBUMS_ENDPOINT = http://localhost:3002 -# The URL of the family plans web app deployment +# The URL of the family plans web app # # Currently the source code for the family plan related pages is in a separate # repository (https://github.com/ente-io/families). The mobile app also uses @@ -77,7 +77,7 @@ # # Enhancement: Consider moving that into the app/ folder in this repository. # -# NEXT_PUBLIC_ENTE_FAMILY_ENDPOINT = http://localhost:3001 +# NEXT_PUBLIC_ENTE_FAMILY_URL = http://localhost:3001 # The JSON which describes the expected results of our integration tests. See # `upload.test.ts` for more details of the expected format. diff --git a/web/apps/photos/.env.development b/web/apps/photos/.env.development index fd4d63c081..1da6070f45 100644 --- a/web/apps/photos/.env.development +++ b/web/apps/photos/.env.development @@ -11,15 +11,21 @@ #NEXT_PUBLIC_ENTE_ENDPOINT = http://localhost:8080 -# If you wish to preview how the shared albums work, you can use `yarn -# dev:albums`. You'll need to run two instances. - -# The equivalent CLI commands using env vars would be: +# If you wish to preview how the shared albums work, you can also uncomment +# this, and run two apps: # -# # For the normal web app -# NEXT_PUBLIC_ENTE_ENDPOINT=http://localhost:8080 NEXT_PUBLIC_ENTE_ALBUMS_ENDPOINT=http://localhost:3002 yarn dev:photos -# -# # For the albums app -# NEXT_PUBLIC_ENTE_ENDPOINT=http://localhost:8080 NEXT_PUBLIC_ENTE_ALBUMS_ENDPOINT=http://localhost:3002 yarn dev:albums +# - `yarn dev:photos` (the main app) +# - `yarn dev:albums` (the sidecar app, in this case, albums) #NEXT_PUBLIC_ENTE_ALBUMS_ENDPOINT = http://localhost:3002 + +# We also have various sidecar apps. These all run on a separate port, 3001, +# since usually when developing we usually need to run only one of them in +# addition to the main photos app. So you can uncomment this entire set. +# +# You'll also need to create a similar `.env.local` or `.env.development.local` +# in the app you're running (e.g. in apps/accounts), and put an +# `NEXT_PUBLIC_ENTE_ENDPOINT` in there. + +#NEXT_PUBLIC_ENTE_ACCOUNTS_URL = http://localhost:3001 +#NEXT_PUBLIC_ENTE_PAYMENTS_URL = http://localhost:3001 diff --git a/web/apps/photos/src/components/AuthenticateUserModal.tsx b/web/apps/photos/src/components/AuthenticateUserModal.tsx index 52be5b7de0..3e54a86c0a 100644 --- a/web/apps/photos/src/components/AuthenticateUserModal.tsx +++ b/web/apps/photos/src/components/AuthenticateUserModal.tsx @@ -71,7 +71,7 @@ export default function AuthenticateUserModal({ onClose={onClose} sx={{ position: "absolute" }} attributes={{ - title: t("PASSWORD"), + title: t("password"), }} > ( ) : ( diff --git a/web/apps/photos/src/components/Menu/EnteMenuItem.tsx b/web/apps/photos/src/components/Menu/EnteMenuItem.tsx deleted file mode 100644 index eb473ebd60..0000000000 --- a/web/apps/photos/src/components/Menu/EnteMenuItem.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { - SpaceBetweenFlex, - VerticallyCenteredFlex, -} from "@ente/shared/components/Container"; -import { - Box, - MenuItem, - Typography, - type ButtonProps, - type TypographyProps, -} from "@mui/material"; -import { CaptionedText } from "components/CaptionedText"; -import PublicShareSwitch from "components/Collections/CollectionShare/publicShare/switch"; -import ChangeDirectoryOption from "components/Directory/changeOption"; -import React from "react"; - -interface Iprops { - onClick: () => void; - color?: ButtonProps["color"]; - variant?: - | "primary" - | "captioned" - | "toggle" - | "secondary" - | "mini" - | "path"; - fontWeight?: TypographyProps["fontWeight"]; - startIcon?: React.ReactNode; - endIcon?: React.ReactNode; - label?: string; - subText?: string; - subIcon?: React.ReactNode; - checked?: boolean; - labelComponent?: React.ReactNode; - disabled?: boolean; -} -export function EnteMenuItem({ - onClick, - color = "primary", - startIcon, - endIcon, - label, - subText, - subIcon, - checked, - variant = "primary", - fontWeight = "bold", - labelComponent, - disabled = false, -}: Iprops) { - const handleButtonClick = () => { - if (variant === "path" || variant === "toggle") { - return; - } - onClick(); - }; - - const handleIconClick = () => { - if (variant !== "path" && variant !== "toggle") { - return; - } - onClick(); - }; - - return ( - - variant !== "captioned" && theme.palette[color].main, - ...(variant !== "secondary" && - variant !== "mini" && { - backgroundColor: (theme) => theme.colors.fill.faint, - }), - "&:hover": { - backgroundColor: (theme) => theme.colors.fill.faintPressed, - }, - "& .MuiSvgIcon-root": { - fontSize: "20px", - }, - p: 0, - borderRadius: "4px", - }} - > - - - {startIcon && startIcon} - - {labelComponent ? ( - labelComponent - ) : variant === "captioned" ? ( - - ) : variant === "mini" ? ( - - {label} - - ) : ( - - {label} - - )} - - - - {endIcon && endIcon} - {variant === "toggle" && ( - - )} - {variant === "path" && ( - - )} - - - - ); -} diff --git a/web/apps/photos/src/components/PhotoList/index.tsx b/web/apps/photos/src/components/PhotoList/index.tsx index 5ac6b263ed..bfc06d8c78 100644 --- a/web/apps/photos/src/components/PhotoList/index.tsx +++ b/web/apps/photos/src/components/PhotoList/index.tsx @@ -1,5 +1,5 @@ import { FlexWrapper } from "@ente/shared/components/Container"; -import { formatDate, getDate, isSameDay } from "@ente/shared/time/format"; +import { formatDate } from "@ente/shared/time/format"; import { Box, Checkbox, Link, Typography, styled } from "@mui/material"; import { DATE_CONTAINER_HEIGHT, @@ -26,7 +26,6 @@ import { handleSelectCreator } from "utils/photoFrame"; import { PublicCollectionGalleryContext } from "utils/publicCollectionGallery"; import { formattedByteSize } from "utils/units"; -const A_DAY = 24 * 60 * 60 * 1000; const FOOTER_HEIGHT = 90; const ALBUM_FOOTER_HEIGHT = 75; const ALBUM_FOOTER_HEIGHT_WITH_REFERRAL = 113; @@ -887,3 +886,24 @@ export function PhotoList({ ); } + +const A_DAY = 24 * 60 * 60 * 1000; + +const getDate = (item: EnteFile) => { + const currentDate = item.metadata.creationTime / 1000; + const date = isSameDay(new Date(currentDate), new Date()) + ? t("TODAY") + : isSameDay(new Date(currentDate), new Date(Date.now() - A_DAY)) + ? t("YESTERDAY") + : formatDate(currentDate); + + return date; +}; + +const isSameDay = (first: Date, second: Date) => { + return ( + first.getFullYear() === second.getFullYear() && + first.getMonth() === second.getMonth() && + first.getDate() === second.getDate() + ); +}; diff --git a/web/apps/photos/src/components/PhotoViewer/ImageEditorOverlay/ColoursMenu.tsx b/web/apps/photos/src/components/PhotoViewer/ImageEditorOverlay/ColoursMenu.tsx index 7c8189c560..deaebe2cf2 100644 --- a/web/apps/photos/src/components/PhotoViewer/ImageEditorOverlay/ColoursMenu.tsx +++ b/web/apps/photos/src/components/PhotoViewer/ImageEditorOverlay/ColoursMenu.tsx @@ -1,5 +1,5 @@ +import { EnteMenuItem } from "@ente/shared/components/Menu/EnteMenuItem"; import { Box, Slider } from "@mui/material"; -import { EnteMenuItem } from "components/Menu/EnteMenuItem"; import { MenuItemGroup } from "components/Menu/MenuItemGroup"; import MenuSectionTitle from "components/Menu/MenuSectionTitle"; import { t } from "i18next"; diff --git a/web/apps/photos/src/components/PhotoViewer/ImageEditorOverlay/CropMenu.tsx b/web/apps/photos/src/components/PhotoViewer/ImageEditorOverlay/CropMenu.tsx index 6b84aee657..11916a13a1 100644 --- a/web/apps/photos/src/components/PhotoViewer/ImageEditorOverlay/CropMenu.tsx +++ b/web/apps/photos/src/components/PhotoViewer/ImageEditorOverlay/CropMenu.tsx @@ -1,5 +1,5 @@ +import { EnteMenuItem } from "@ente/shared/components/Menu/EnteMenuItem"; import CropIcon from "@mui/icons-material/Crop"; -import { EnteMenuItem } from "components/Menu/EnteMenuItem"; import { MenuItemGroup } from "components/Menu/MenuItemGroup"; import MenuSectionTitle from "components/Menu/MenuSectionTitle"; import { t } from "i18next"; diff --git a/web/apps/photos/src/components/PhotoViewer/ImageEditorOverlay/TransformMenu.tsx b/web/apps/photos/src/components/PhotoViewer/ImageEditorOverlay/TransformMenu.tsx index 3dd8f1a426..6176ab3c18 100644 --- a/web/apps/photos/src/components/PhotoViewer/ImageEditorOverlay/TransformMenu.tsx +++ b/web/apps/photos/src/components/PhotoViewer/ImageEditorOverlay/TransformMenu.tsx @@ -1,11 +1,11 @@ import log from "@/next/log"; +import { EnteMenuItem } from "@ente/shared/components/Menu/EnteMenuItem"; import Crop169Icon from "@mui/icons-material/Crop169"; import Crop32Icon from "@mui/icons-material/Crop32"; import CropSquareIcon from "@mui/icons-material/CropSquare"; import FlipIcon from "@mui/icons-material/Flip"; import RotateLeftIcon from "@mui/icons-material/RotateLeft"; import RotateRightIcon from "@mui/icons-material/RotateRight"; -import { EnteMenuItem } from "components/Menu/EnteMenuItem"; import MenuItemDivider from "components/Menu/MenuItemDivider"; import { MenuItemGroup } from "components/Menu/MenuItemGroup"; import MenuSectionTitle from "components/Menu/MenuSectionTitle"; diff --git a/web/apps/photos/src/components/PhotoViewer/ImageEditorOverlay/index.tsx b/web/apps/photos/src/components/PhotoViewer/ImageEditorOverlay/index.tsx index 52e1476a97..9bb27b4b2a 100644 --- a/web/apps/photos/src/components/PhotoViewer/ImageEditorOverlay/index.tsx +++ b/web/apps/photos/src/components/PhotoViewer/ImageEditorOverlay/index.tsx @@ -6,6 +6,7 @@ import { HorizontalFlex, } from "@ente/shared/components/Container"; import EnteButton from "@ente/shared/components/EnteButton"; +import { EnteMenuItem } from "@ente/shared/components/Menu/EnteMenuItem"; import { downloadUsingAnchor } from "@ente/shared/utils"; import ChevronRightIcon from "@mui/icons-material/ChevronRight"; import CloseIcon from "@mui/icons-material/Close"; @@ -24,7 +25,6 @@ import { Typography, } from "@mui/material"; import { EnteDrawer } from "components/EnteDrawer"; -import { EnteMenuItem } from "components/Menu/EnteMenuItem"; import MenuItemDivider from "components/Menu/MenuItemDivider"; import { MenuItemGroup } from "components/Menu/MenuItemGroup"; import MenuSectionTitle from "components/Menu/MenuSectionTitle"; diff --git a/web/apps/photos/src/components/Sidebar/AdvancedSettings.tsx b/web/apps/photos/src/components/Sidebar/AdvancedSettings.tsx index ed03bc9175..c39649b4bf 100644 --- a/web/apps/photos/src/components/Sidebar/AdvancedSettings.tsx +++ b/web/apps/photos/src/components/Sidebar/AdvancedSettings.tsx @@ -1,9 +1,9 @@ import { VerticallyCenteredFlex } from "@ente/shared/components/Container"; +import { EnteMenuItem } from "@ente/shared/components/Menu/EnteMenuItem"; import ChevronRight from "@mui/icons-material/ChevronRight"; import ScienceIcon from "@mui/icons-material/Science"; import { Box, DialogProps, Stack, Typography } from "@mui/material"; import { EnteDrawer } from "components/EnteDrawer"; -import { EnteMenuItem } from "components/Menu/EnteMenuItem"; import { MenuItemGroup } from "components/Menu/MenuItemGroup"; import MenuSectionTitle from "components/Menu/MenuSectionTitle"; import Titlebar from "components/Titlebar"; diff --git a/web/apps/photos/src/components/Sidebar/MapSetting.tsx b/web/apps/photos/src/components/Sidebar/MapSetting.tsx index 430f7667f5..18d1a0639a 100644 --- a/web/apps/photos/src/components/Sidebar/MapSetting.tsx +++ b/web/apps/photos/src/components/Sidebar/MapSetting.tsx @@ -1,4 +1,5 @@ import log from "@/next/log"; +import { EnteMenuItem } from "@ente/shared/components/Menu/EnteMenuItem"; import { Box, Button, @@ -8,7 +9,6 @@ import { Typography, } from "@mui/material"; import { EnteDrawer } from "components/EnteDrawer"; -import { EnteMenuItem } from "components/Menu/EnteMenuItem"; import { MenuItemGroup } from "components/Menu/MenuItemGroup"; import Titlebar from "components/Titlebar"; import { t } from "i18next"; diff --git a/web/apps/photos/src/components/Sidebar/Preferences.tsx b/web/apps/photos/src/components/Sidebar/Preferences.tsx index 8d4ae10588..4090d49194 100644 --- a/web/apps/photos/src/components/Sidebar/Preferences.tsx +++ b/web/apps/photos/src/components/Sidebar/Preferences.tsx @@ -4,11 +4,11 @@ import { supportedLocales, type SupportedLocale, } from "@/next/i18n"; +import { EnteMenuItem } from "@ente/shared/components/Menu/EnteMenuItem"; import ChevronRight from "@mui/icons-material/ChevronRight"; import { Box, DialogProps, Stack } from "@mui/material"; import DropdownInput from "components/DropdownInput"; import { EnteDrawer } from "components/EnteDrawer"; -import { EnteMenuItem } from "components/Menu/EnteMenuItem"; import Titlebar from "components/Titlebar"; import { t } from "i18next"; import { useRouter } from "next/router"; diff --git a/web/apps/photos/src/components/Sidebar/index.tsx b/web/apps/photos/src/components/Sidebar/index.tsx index 489536e085..dc8c7b9c64 100644 --- a/web/apps/photos/src/components/Sidebar/index.tsx +++ b/web/apps/photos/src/components/Sidebar/index.tsx @@ -1,27 +1,14 @@ import log from "@/next/log"; import { savedLogs } from "@/next/log-web"; -import { - configurePasskeyRecovery, - isPasskeyRecoveryEnabled, -} from "@ente/accounts/services/passkey"; -import { APPS, CLIENT_PACKAGE_NAMES } from "@ente/shared/apps/constants"; +import { openAccountsManagePasskeysPage } from "@ente/accounts/services/passkey"; import { SpaceBetweenFlex } from "@ente/shared/components/Container"; import { EnteLogo } from "@ente/shared/components/EnteLogo"; import EnteSpinner from "@ente/shared/components/EnteSpinner"; +import { EnteMenuItem } from "@ente/shared/components/Menu/EnteMenuItem"; import RecoveryKey from "@ente/shared/components/RecoveryKey"; import ThemeSwitcher from "@ente/shared/components/ThemeSwitcher"; -import { - ACCOUNTS_PAGES, - PHOTOS_PAGES as PAGES, -} from "@ente/shared/constants/pages"; -import ComlinkCryptoWorker from "@ente/shared/crypto"; -import { getRecoveryKey } from "@ente/shared/crypto/helpers"; -import { - encryptToB64, - generateEncryptionKey, -} from "@ente/shared/crypto/internal/libsodium"; +import { PHOTOS_PAGES as PAGES } from "@ente/shared/constants/pages"; import { useLocalState } from "@ente/shared/hooks/useLocalState"; -import { getAccountsURL } from "@ente/shared/network/api"; import { LS_KEYS, getData, setData } from "@ente/shared/storage/localStorage"; import { THEME_COLOR } from "@ente/shared/themes/constants"; import { downloadAsFile } from "@ente/shared/utils"; @@ -42,7 +29,6 @@ import { import Typography from "@mui/material/Typography"; import DeleteAccountModal from "components/DeleteAccountModal"; import { EnteDrawer } from "components/EnteDrawer"; -import { EnteMenuItem } from "components/Menu/EnteMenuItem"; import TwoFactorModal from "components/TwoFactor/Modal"; import { WatchFolder } from "components/WatchFolder"; import LinkButton from "components/pages/gallery/LinkButton"; @@ -68,7 +54,7 @@ import { Trans } from "react-i18next"; import billingService from "services/billingService"; import { getUncategorizedCollection } from "services/collectionService"; import exportService from "services/export"; -import { getAccountsToken, getUserDetailsV2 } from "services/userService"; +import { getUserDetailsV2 } from "services/userService"; import { CollectionSummaries } from "types/collection"; import { UserDetails } from "types/user"; import { @@ -486,36 +472,7 @@ const UtilitySection: React.FC = ({ closeSidebar }) => { closeSidebar(); try { - // check if the user has passkey recovery enabled - const recoveryEnabled = await isPasskeyRecoveryEnabled(); - if (!recoveryEnabled) { - // let's create the necessary recovery information - const recoveryKey = await getRecoveryKey(); - - const resetSecret = await generateEncryptionKey(); - - const cryptoWorker = await ComlinkCryptoWorker.getInstance(); - const encryptionResult = await encryptToB64( - resetSecret, - await cryptoWorker.fromHex(recoveryKey), - ); - - await configurePasskeyRecovery( - resetSecret, - encryptionResult.encryptedData, - encryptionResult.nonce, - ); - } - - const accountsToken = await getAccountsToken(); - - window.open( - `${getAccountsURL()}${ - ACCOUNTS_PAGES.ACCOUNT_HANDOFF - }?package=${CLIENT_PACKAGE_NAMES.get( - APPS.PHOTOS, - )}&token=${accountsToken}`, - ); + await openAccountsManagePasskeysPage(); } catch (e) { log.error("failed to redirect to accounts page", e); } @@ -571,13 +528,11 @@ const UtilitySection: React.FC = ({ closeSidebar }) => { label={t("TWO_FACTOR")} /> - {isInternalUserViaEmailCheck() && ( - - )} + units[u] || u === "second") + return relativeDateFormat.format( + Math.round(elapsed / units[u]), + u as Intl.RelativeTimeFormatUnit, + ); +} diff --git a/web/apps/photos/src/pages/_app.tsx b/web/apps/photos/src/pages/_app.tsx index f3435a690c..1cdcb6b3b8 100644 --- a/web/apps/photos/src/pages/_app.tsx +++ b/web/apps/photos/src/pages/_app.tsx @@ -1,18 +1,19 @@ import { CustomHead } from "@/next/components/Head"; +import { setAppNameForAuthenticatedRequests } from "@/next/http"; import { setupI18n } from "@/next/i18n"; import log from "@/next/log"; import { logStartupBanner, logUnhandledErrorsAndRejections, } from "@/next/log-web"; -import type { AppName, BaseAppContextT } from "@/next/types/app"; +import { + appTitle, + clientPackageName, + type AppName, + type BaseAppContextT, +} from "@/next/types/app"; import { AppUpdate } from "@/next/types/ipc"; import { ensure } from "@/utils/ensure"; -import { - APPS, - APP_TITLES, - CLIENT_PACKAGE_NAMES, -} from "@ente/shared/apps/constants"; import { Overlay } from "@ente/shared/components/Container"; import DialogBox from "@ente/shared/components/DialogBox"; import { @@ -131,7 +132,7 @@ export default function App({ Component, pageProps }: AppProps) { const [dialogBoxV2View, setDialogBoxV2View] = useState(false); const [watchFolderView, setWatchFolderView] = useState(false); const [watchFolderFiles, setWatchFolderFiles] = useState(null); - const isMobile = useMediaQuery("(max-width:428px)"); + const isMobile = useMediaQuery("(max-width: 428px)"); const [notificationView, setNotificationView] = useState(false); const closeNotification = () => setNotificationView(false); const [notificationAttributes, setNotificationAttributes] = @@ -146,12 +147,13 @@ export default function App({ Component, pageProps }: AppProps) { ); useEffect(() => { - setupI18n().finally(() => setIsI18nReady(true)); + void setupI18n().finally(() => setIsI18nReady(true)); const userId = (getData(LS_KEYS.USER) as User)?.id; - logStartupBanner(APPS.PHOTOS, userId); + logStartupBanner(appName, userId); logUnhandledErrorsAndRejections(true); + setAppNameForAuthenticatedRequests(appName); HTTPService.setHeaders({ - "X-Client-Package": CLIENT_PACKAGE_NAMES.get(APPS.PHOTOS), + "X-Client-Package": clientPackageName(appName), }); return () => logUnhandledErrorsAndRejections(false); }, []); @@ -160,6 +162,15 @@ export default function App({ Component, pageProps }: AppProps) { const electron = globalThis.electron; if (!electron) return; + // Attach various listeners for events sent to us by the Node.js layer. + // This is for events that we should listen for always, not just when + // the user is logged in. + + const handleOpenURL = (url: string) => { + if (url.startsWith("ente://app")) router.push(url); + else log.info(`Ignoring unhandled open request for URL ${url}`); + }; + const showUpdateDialog = (update: AppUpdate) => { if (update.autoUpdatable) { setDialogMessage(getUpdateReadyToInstallMessage(update)); @@ -175,9 +186,14 @@ export default function App({ Component, pageProps }: AppProps) { }); } }; + + electron.onOpenURL(handleOpenURL); electron.onAppUpdateAvailable(showUpdateDialog); - return () => electron.onAppUpdateAvailable(undefined); + return () => { + electron.onOpenURL(undefined); + electron.onAppUpdateAvailable(undefined); + }; }, []); useEffect(() => { @@ -207,7 +223,7 @@ export default function App({ Component, pageProps }: AppProps) { const initExport = async () => { const token = getToken(); if (!token) return; - await DownloadManager.init(APPS.PHOTOS, { token }); + await DownloadManager.init(token); await resumeExportsIfNeeded(); }; initExport(); @@ -358,13 +374,13 @@ export default function App({ Component, pageProps }: AppProps) { const title = isI18nReady ? t("title", { context: "photos" }) - : APP_TITLES.get(APPS.PHOTOS); + : appTitle[appName]; return ( <> - + {showNavbar && } @@ -385,6 +401,7 @@ export default function App({ Component, pageProps }: AppProps) { onClose={closeDialogBoxV2} attributes={dialogBoxAttributeV2} /> + (null); const [shouldDisableDropzone, setShouldDisableDropzone] = useState(false); const [isPhotoSwipeOpen, setIsPhotoSwipeOpen] = useState(false); + // TODO(MR): This is never true currently, this is the WIP ability to show + // what's new dialog on desktop app updates. The UI is done, need to hook + // this up to logic to trigger it. + const [openWhatsNew, setOpenWhatsNew] = useState(false); const { // A function to call to get the props we should apply to the container, @@ -347,7 +352,7 @@ export default function Gallery() { if (!valid) { return; } - await downloadManager.init(APPS.PHOTOS, { token }); + await downloadManager.init(token); setupSelectAllKeyBoardShortcutHandler(); setActiveCollectionID(ALL_SECTION); setIsFirstLoad(isFirstLogin()); @@ -387,6 +392,7 @@ export default function Gallery() { if (electron) { // void clipService.setupOnFileUploadListener(); electron.onMainWindowFocus(() => syncWithRemote(false, true)); + if (await shouldShowWhatsNew()) setOpenWhatsNew(true); } }; main(); @@ -1155,6 +1161,10 @@ export default function Gallery() { sidebarView={sidebarView} closeSidebar={closeSidebar} /> + setOpenWhatsNew(false)} + /> {!isInSearchMode && !isFirstLoad && !files?.length && diff --git a/web/apps/photos/src/pages/passkeys/finish.tsx b/web/apps/photos/src/pages/passkeys/finish.tsx index 866dcf9e3a..17f8e47eb4 100644 --- a/web/apps/photos/src/pages/passkeys/finish.tsx +++ b/web/apps/photos/src/pages/passkeys/finish.tsx @@ -1,3 +1,6 @@ -import Page from "@ente/accounts/pages/passkeys/finish"; +import Page_ from "@ente/accounts/pages/passkeys/finish"; +import { useAppContext } from "../_app"; + +const Page = () => ; export default Page; diff --git a/web/apps/photos/src/pages/passkeys/recover.tsx b/web/apps/photos/src/pages/passkeys/recover.tsx new file mode 100644 index 0000000000..5bc2230c87 --- /dev/null +++ b/web/apps/photos/src/pages/passkeys/recover.tsx @@ -0,0 +1,8 @@ +import Page_ from "@ente/accounts/pages/two-factor/recover"; +import { useAppContext } from "../_app"; + +const Page = () => ( + +); + +export default Page; diff --git a/web/apps/photos/src/pages/shared-albums/index.tsx b/web/apps/photos/src/pages/shared-albums/index.tsx index ff7e20eea8..b35bb7f82c 100644 --- a/web/apps/photos/src/pages/shared-albums/index.tsx +++ b/web/apps/photos/src/pages/shared-albums/index.tsx @@ -1,5 +1,4 @@ import log from "@/next/log"; -import { APPS } from "@ente/shared/apps/constants"; import { CenteredFlex, SpaceBetweenFlex, @@ -212,7 +211,7 @@ export default function PublicCollectionGallery() { let redirectingToWebsite = false; try { const cryptoWorker = await ComlinkCryptoWorker.getInstance(); - await downloadManager.init(APPS.ALBUMS); + await downloadManager.init(); url.current = window.location.href; const currentURL = new URL(url.current); @@ -487,14 +486,14 @@ export default function PublicCollectionGallery() { return ( - {t("PASSWORD")} + {t("password")} - {t("LINK_PASSWORD")} + {t("link_password_description")} diff --git a/web/apps/photos/src/pages/two-factor/recover.tsx b/web/apps/photos/src/pages/two-factor/recover.tsx index d3f40be49c..61414077e5 100644 --- a/web/apps/photos/src/pages/two-factor/recover.tsx +++ b/web/apps/photos/src/pages/two-factor/recover.tsx @@ -1,6 +1,6 @@ import Page_ from "@ente/accounts/pages/two-factor/recover"; import { useAppContext } from "../_app"; -const Page = () => ; +const Page = () => ; export default Page; diff --git a/web/apps/photos/src/services/download/clients/photos.ts b/web/apps/photos/src/services/download/clients/photos.ts index 6a4d9cddba..9f4ecc6079 100644 --- a/web/apps/photos/src/services/download/clients/photos.ts +++ b/web/apps/photos/src/services/download/clients/photos.ts @@ -1,6 +1,6 @@ import { CustomError } from "@ente/shared/error"; import HTTPService from "@ente/shared/network/HTTPService"; -import { getFileURL, getThumbnailURL } from "@ente/shared/network/api"; +import { customAPIOrigin } from "@ente/shared/network/api"; import { retryAsyncFunction } from "@ente/shared/utils"; import { DownloadClient } from "services/download"; import { EnteFile } from "types/file"; @@ -16,20 +16,33 @@ export class PhotosDownloadClient implements DownloadClient { } async downloadThumbnail(file: EnteFile): Promise { - if (!this.token) { - throw Error(CustomError.TOKEN_MISSING); - } - const resp = await retryAsyncFunction(() => - HTTPService.get( - getThumbnailURL(file.id), - null, - { "X-Auth-Token": this.token }, - { responseType: "arraybuffer", timeout: this.timeout }, - ), - ); - if (typeof resp.data === "undefined") { - throw Error(CustomError.REQUEST_FAILED); - } + const token = this.token; + if (!token) throw Error(CustomError.TOKEN_MISSING); + + // See: [Note: Passing credentials for self-hosted file fetches] + const getThumbnail = () => { + const opts = { responseType: "arraybuffer", timeout: this.timeout }; + const customOrigin = customAPIOrigin(); + if (customOrigin) { + const params = new URLSearchParams({ token }); + return HTTPService.get( + `${customOrigin}/files/preview/${file.id}?${params.toString()}`, + undefined, + undefined, + opts, + ); + } else { + return HTTPService.get( + `https://thumbnails.ente.io/?fileID=${file.id}`, + undefined, + { "X-Auth-Token": token }, + opts, + ); + } + }; + + const resp = await retryAsyncFunction(getThumbnail); + if (resp.data === undefined) throw Error(CustomError.REQUEST_FAILED); return new Uint8Array(resp.data); } @@ -37,37 +50,97 @@ export class PhotosDownloadClient implements DownloadClient { file: EnteFile, onDownloadProgress: (event: { loaded: number; total: number }) => void, ): Promise { - if (!this.token) { - throw Error(CustomError.TOKEN_MISSING); - } - const resp = await retryAsyncFunction(() => - HTTPService.get( - getFileURL(file.id), - null, - { "X-Auth-Token": this.token }, - { - responseType: "arraybuffer", - timeout: this.timeout, - onDownloadProgress, - }, - ), - ); - if (typeof resp.data === "undefined") { - throw Error(CustomError.REQUEST_FAILED); - } + const token = this.token; + if (!token) throw Error(CustomError.TOKEN_MISSING); + + // See: [Note: Passing credentials for self-hosted file fetches] + const getFile = () => { + const opts = { + responseType: "arraybuffer", + timeout: this.timeout, + onDownloadProgress, + }; + + const customOrigin = customAPIOrigin(); + if (customOrigin) { + const params = new URLSearchParams({ token }); + return HTTPService.get( + `${customOrigin}/files/download/${file.id}?${params.toString()}`, + undefined, + undefined, + opts, + ); + } else { + return HTTPService.get( + `https://files.ente.io/?fileID=${file.id}`, + undefined, + { "X-Auth-Token": token }, + opts, + ); + } + }; + + const resp = await retryAsyncFunction(getFile); + if (resp.data === undefined) throw Error(CustomError.REQUEST_FAILED); return new Uint8Array(resp.data); } async downloadFileStream(file: EnteFile): Promise { - if (!this.token) { - throw Error(CustomError.TOKEN_MISSING); - } - return retryAsyncFunction(() => - fetch(getFileURL(file.id), { - headers: { - "X-Auth-Token": this.token, - }, - }), - ); + const token = this.token; + if (!token) throw Error(CustomError.TOKEN_MISSING); + + // [Note: Passing credentials for self-hosted file fetches] + // + // Fetching files (or thumbnails) in the default self-hosted Ente + // configuration involves a redirection: + // + // 1. The browser makes a HTTP GET to a museum with credentials. Museum + // inspects the credentials, in this case the auth token, and if + // they're valid, returns a HTTP 307 redirect to the pre-signed S3 + // URL that to the file in the configured S3 bucket. + // + // 2. The browser follows the redirect to get the actual file. The URL + // is pre-signed, i.e. already has all credentials needed to prove to + // the S3 object storage that it should serve this response. + // + // For the first step normally we'd pass the auth the token via the + // "X-Auth-Token" HTTP header. In this case though, that would be + // problematic because the browser preserves the request headers when it + // follows the HTTP 307 redirect, and the "X-Auth-Token" header also + // gets sent to the redirected S3 request made in second step. + // + // To avoid this, we pass the token as a query parameter. Generally this + // is not a good idea, but in this case (a) the URL is not a user + // visible one and (b) even if it gets logged, it'll be in the + // self-hosters own service. + // + // Note that Ente's own servers don't have these concerns because we use + // a slightly different flow involving a proxy instead of directly + // connecting to the S3 storage. + // + // 1. The web browser makes a HTTP GET request to a proxy passing it the + // credentials in the "X-Auth-Token". + // + // 2. The proxy then does both the original steps: (a). Use the + // credentials to get the pre signed URL, and (b) fetch that pre + // signed URL and stream back the response. + + const getFile = () => { + const customOrigin = customAPIOrigin(); + if (customOrigin) { + const params = new URLSearchParams({ token }); + return fetch( + `${customOrigin}/files/download/${file.id}?${params.toString()}`, + ); + } else { + return fetch(`https://files.ente.io/?fileID=${file.id}`, { + headers: { + "X-Auth-Token": token, + }, + }); + } + }; + + return retryAsyncFunction(getFile); } } diff --git a/web/apps/photos/src/services/download/clients/publicAlbums.ts b/web/apps/photos/src/services/download/clients/publicAlbums.ts index 48cb2292a4..4db8d2cf45 100644 --- a/web/apps/photos/src/services/download/clients/publicAlbums.ts +++ b/web/apps/photos/src/services/download/clients/publicAlbums.ts @@ -1,19 +1,15 @@ import { CustomError } from "@ente/shared/error"; import HTTPService from "@ente/shared/network/HTTPService"; -import { - getPublicCollectionFileURL, - getPublicCollectionThumbnailURL, -} from "@ente/shared/network/api"; +import { customAPIOrigin } from "@ente/shared/network/api"; import { retryAsyncFunction } from "@ente/shared/utils"; import { DownloadClient } from "services/download"; import { EnteFile } from "types/file"; export class PublicAlbumsDownloadClient implements DownloadClient { - constructor( - private token: string, - private passwordToken: string, - private timeout: number, - ) {} + private token: string; + private passwordToken: string; + + constructor(private timeout: number) {} updateTokens(token: string, passwordToken: string) { this.token = token; @@ -21,24 +17,45 @@ export class PublicAlbumsDownloadClient implements DownloadClient { } downloadThumbnail = async (file: EnteFile) => { - if (!this.token) { - throw Error(CustomError.TOKEN_MISSING); - } - const resp = await HTTPService.get( - getPublicCollectionThumbnailURL(file.id), - null, - { - "X-Auth-Access-Token": this.token, - ...(this.passwordToken && { - "X-Auth-Access-Token-JWT": this.passwordToken, - }), - }, - { responseType: "arraybuffer" }, - ); + const accessToken = this.token; + const accessTokenJWT = this.passwordToken; + if (!accessToken) throw Error(CustomError.TOKEN_MISSING); - if (typeof resp.data === "undefined") { - throw Error(CustomError.REQUEST_FAILED); - } + // See: [Note: Passing credentials for self-hosted file fetches] + const getThumbnail = () => { + const opts = { + responseType: "arraybuffer", + }; + + const customOrigin = customAPIOrigin(); + if (customOrigin) { + const params = new URLSearchParams({ + accessToken, + ...(accessTokenJWT && { accessTokenJWT }), + }); + return HTTPService.get( + `${customOrigin}/public-collection/files/preview/${file.id}?${params.toString()}`, + undefined, + undefined, + opts, + ); + } else { + return HTTPService.get( + `https://public-albums.ente.io/preview/?fileID=${file.id}`, + undefined, + { + "X-Auth-Access-Token": accessToken, + ...(accessTokenJWT && { + "X-Auth-Access-Token-JWT": accessTokenJWT, + }), + }, + opts, + ); + } + }; + + const resp = await getThumbnail(); + if (resp.data === undefined) throw Error(CustomError.REQUEST_FAILED); return new Uint8Array(resp.data); }; @@ -46,46 +63,81 @@ export class PublicAlbumsDownloadClient implements DownloadClient { file: EnteFile, onDownloadProgress: (event: { loaded: number; total: number }) => void, ) => { - if (!this.token) { - throw Error(CustomError.TOKEN_MISSING); - } - const resp = await retryAsyncFunction(() => - HTTPService.get( - getPublicCollectionFileURL(file.id), - null, - { - "X-Auth-Access-Token": this.token, - ...(this.passwordToken && { - "X-Auth-Access-Token-JWT": this.passwordToken, - }), - }, - { - responseType: "arraybuffer", - timeout: this.timeout, - onDownloadProgress, - }, - ), - ); + const accessToken = this.token; + const accessTokenJWT = this.passwordToken; + if (!accessToken) throw Error(CustomError.TOKEN_MISSING); - if (typeof resp.data === "undefined") { - throw Error(CustomError.REQUEST_FAILED); - } + // See: [Note: Passing credentials for self-hosted file fetches] + const getFile = () => { + const opts = { + responseType: "arraybuffer", + timeout: this.timeout, + onDownloadProgress, + }; + + const customOrigin = customAPIOrigin(); + if (customOrigin) { + const params = new URLSearchParams({ + accessToken, + ...(accessTokenJWT && { accessTokenJWT }), + }); + return HTTPService.get( + `${customOrigin}/public-collection/files/download/${file.id}?${params.toString()}`, + undefined, + undefined, + opts, + ); + } else { + return HTTPService.get( + `https://public-albums.ente.io/download/?fileID=${file.id}`, + undefined, + { + "X-Auth-Access-Token": accessToken, + ...(accessTokenJWT && { + "X-Auth-Access-Token-JWT": accessTokenJWT, + }), + }, + opts, + ); + } + }; + + const resp = await retryAsyncFunction(getFile); + if (resp.data === undefined) throw Error(CustomError.REQUEST_FAILED); return new Uint8Array(resp.data); }; async downloadFileStream(file: EnteFile): Promise { - if (!this.token) { - throw Error(CustomError.TOKEN_MISSING); - } - return retryAsyncFunction(() => - fetch(getPublicCollectionFileURL(file.id), { - headers: { - "X-Auth-Access-Token": this.token, - ...(this.passwordToken && { - "X-Auth-Access-Token-JWT": this.passwordToken, - }), - }, - }), - ); + const accessToken = this.token; + const accessTokenJWT = this.passwordToken; + if (!accessToken) throw Error(CustomError.TOKEN_MISSING); + + // See: [Note: Passing credentials for self-hosted file fetches] + const getFile = () => { + const customOrigin = customAPIOrigin(); + if (customOrigin) { + const params = new URLSearchParams({ + accessToken, + ...(accessTokenJWT && { accessTokenJWT }), + }); + return fetch( + `${customOrigin}/public-collection/files/download/${file.id}?${params.toString()}`, + ); + } else { + return fetch( + `https://public-albums.ente.io/download/?fileID=${file.id}`, + { + headers: { + "X-Auth-Access-Token": accessToken, + ...(accessTokenJWT && { + "X-Auth-Access-Token-JWT": accessTokenJWT, + }), + }, + }, + ); + } + }; + + return retryAsyncFunction(getFile); } } diff --git a/web/apps/photos/src/services/download/index.ts b/web/apps/photos/src/services/download/index.ts index a077c16d97..a080acd92e 100644 --- a/web/apps/photos/src/services/download/index.ts +++ b/web/apps/photos/src/services/download/index.ts @@ -2,7 +2,6 @@ import { FILE_TYPE } from "@/media/file-type"; import { decodeLivePhoto } from "@/media/live-photo"; import { blobCache, type BlobCache } from "@/next/blob-cache"; import log from "@/next/log"; -import { APPS } from "@ente/shared/apps/constants"; import ComlinkCryptoWorker from "@ente/shared/crypto"; import { DedicatedCryptoWorker } from "@ente/shared/crypto/internal/crypto.worker"; import { CustomError } from "@ente/shared/error"; @@ -81,15 +80,12 @@ class DownloadManagerImpl { private progressUpdater: (value: Map) => void = () => {}; - async init( - app: APPS, - tokens?: { token: string; passwordToken?: string } | { token: string }, - ) { + async init(token?: string) { if (this.ready) { log.info("DownloadManager already initialized"); return; } - this.downloadClient = createDownloadClient(app, tokens); + this.downloadClient = createDownloadClient(token); try { this.thumbnailCache = await blobCache("thumbs"); } catch (e) { @@ -422,25 +418,14 @@ const DownloadManager = new DownloadManagerImpl(); export default DownloadManager; -function createDownloadClient( - app: APPS, - tokens?: { token: string; passwordToken?: string } | { token: string }, -): DownloadClient { +const createDownloadClient = (token: string): DownloadClient => { const timeout = 300000; // 5 minute - if (app === APPS.ALBUMS) { - if (!tokens) { - tokens = { token: undefined, passwordToken: undefined }; - } - const { token, passwordToken } = tokens as { - token: string; - passwordToken: string; - }; - return new PublicAlbumsDownloadClient(token, passwordToken, timeout); - } else { - const { token } = tokens; + if (token) { return new PhotosDownloadClient(token, timeout); + } else { + return new PublicAlbumsDownloadClient(timeout); } -} +}; async function getRenderableFileURL( file: EnteFile, diff --git a/web/apps/photos/src/services/face/face.worker.ts b/web/apps/photos/src/services/face/face.worker.ts index 0ba2233e70..af0995951f 100644 --- a/web/apps/photos/src/services/face/face.worker.ts +++ b/web/apps/photos/src/services/face/face.worker.ts @@ -1,4 +1,3 @@ -import { APPS } from "@ente/shared/apps/constants"; import { expose } from "comlink"; import downloadManager from "services/download"; import mlService from "services/machineLearning/machineLearningService"; @@ -20,7 +19,7 @@ export class DedicatedMLWorker { } public async sync(token: string, userID: number, userAgent: string) { - await downloadManager.init(APPS.PHOTOS, { token }); + await downloadManager.init(token); return mlService.sync(token, userID, userAgent); } } diff --git a/web/apps/photos/src/services/face/mlWorkManager.ts b/web/apps/photos/src/services/face/mlWorkManager.ts index 3e25864bae..1a89dc1575 100644 --- a/web/apps/photos/src/services/face/mlWorkManager.ts +++ b/web/apps/photos/src/services/face/mlWorkManager.ts @@ -1,8 +1,8 @@ import { FILE_TYPE } from "@/media/file-type"; import { ensureElectron } from "@/next/electron"; import log from "@/next/log"; +import { clientPackageNamePhotosDesktop } from "@/next/types/app"; import { ComlinkWorker } from "@/next/worker/comlink-worker"; -import { clientPackageNamePhotosDesktop } from "@ente/shared/apps/constants"; import { eventBus, Events } from "@ente/shared/events"; import { getToken, getUserID } from "@ente/shared/storage/localStorage/helpers"; import debounce from "debounce"; diff --git a/web/apps/photos/src/services/userService.ts b/web/apps/photos/src/services/userService.ts index 3bfede63a0..6ec018a368 100644 --- a/web/apps/photos/src/services/userService.ts +++ b/web/apps/photos/src/services/userService.ts @@ -65,24 +65,6 @@ export const getFamiliesToken = async () => { } }; -export const getAccountsToken = async () => { - try { - const token = getToken(); - - const resp = await HTTPService.get( - `${ENDPOINT}/users/accounts-token`, - null, - { - "X-Auth-Token": token, - }, - ); - return resp.data["accountsToken"]; - } catch (e) { - log.error("failed to get accounts token", e); - throw e; - } -}; - export const getRoadmapRedirectURL = async () => { try { const token = getToken(); diff --git a/web/apps/photos/src/services/watch.ts b/web/apps/photos/src/services/watch.ts index 82d3b2f4ec..42ce245fa4 100644 --- a/web/apps/photos/src/services/watch.ts +++ b/web/apps/photos/src/services/watch.ts @@ -270,10 +270,11 @@ class FolderWatcher { } const [removed, rest] = watch.syncedFiles.reduce( - ([removed, rest], { path }) => { - (event.filePaths.includes(path) ? rest : removed).push( - watch, - ); + ([removed, rest], syncedFile) => { + (event.filePaths.includes(syncedFile.path) + ? removed + : rest + ).push(syncedFile); return [removed, rest]; }, [[], []], diff --git a/web/apps/staff/.eslintrc.cjs b/web/apps/staff/.eslintrc.cjs deleted file mode 100644 index 99b4b9226c..0000000000 --- a/web/apps/staff/.eslintrc.cjs +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - extends: ["@/build-config/eslintrc-vite"], -}; diff --git a/web/apps/staff/README.md b/web/apps/staff/README.md deleted file mode 100644 index e54b674d3d..0000000000 --- a/web/apps/staff/README.md +++ /dev/null @@ -1,8 +0,0 @@ -## Staff dashboard - -Web app for staff members to help with support etc. - -### Deployment - -The app gets redeployed whenever a PR is merged into main. See -[docs/deploy.md](../../docs/deploy.md) for more details. diff --git a/web/apps/staff/package.json b/web/apps/staff/package.json deleted file mode 100644 index 530f7f8243..0000000000 --- a/web/apps/staff/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "staff", - "version": "0.0.0", - "private": true, - "type": "module", - "scripts": { - "build": "tsc && vite build", - "dev": "vite", - "preview": "vite preview" - }, - "dependencies": { - "react": "^18", - "react-dom": "^18", - "zod": "^3" - }, - "devDependencies": { - "@/build-config": "*", - "@types/react": "^18", - "@types/react-dom": "^18", - "@vitejs/plugin-react": "^4.2", - "vite": "^5.2" - } -} diff --git a/web/apps/staff/src/App.tsx b/web/apps/staff/src/App.tsx deleted file mode 100644 index 01d79b18cc..0000000000 --- a/web/apps/staff/src/App.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import React from "react"; -import { getUserDetails } from "./services/support-service"; -import S from "./utils/strings"; - -export const App: React.FC = () => { - const handleClick = () => { - const authToken = "xxx"; - getUserDetails(authToken) - .then((userDetails) => { - console.log("Fetched user details", userDetails); - }) - .catch((e: unknown) => { - console.error("Failed to fetch user details", e); - }); - }; - - return ( -
-

{S.hello}

-

- help.ente.io -

-

- -

-
- ); -}; diff --git a/web/apps/staff/tsconfig.json b/web/apps/staff/tsconfig.json deleted file mode 100644 index 291fed6caf..0000000000 --- a/web/apps/staff/tsconfig.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "extends": "@/build-config/tsconfig-vite.json", - "include": ["src"], - "references": [{ "path": "./tsconfig.node.json" }] -} diff --git a/web/apps/staff/tsconfig.node.json b/web/apps/staff/tsconfig.node.json deleted file mode 100644 index a8d6e3fc8f..0000000000 --- a/web/apps/staff/tsconfig.node.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "@/build-config/tsconfig-vite.node.json", - "include": ["vite.config.ts"] -} diff --git a/web/docs/deploy.md b/web/docs/deploy.md index 75c3106d18..74c3f25b4d 100644 --- a/web/docs/deploy.md +++ b/web/docs/deploy.md @@ -25,22 +25,22 @@ publish to [web.ente.io](https://web.ente.io). Here is a list of all the deployments, whether or not they are production deployments, and the action that triggers them: -| URL | Type | Deployment action | -| -------------------------------------------- | ---------- | --------------------------------------------- | -| [web.ente.io](https://web.ente.io) | Production | Daily deploy of `main` | -| [photos.ente.io](https://photos.ente.io) | Production | Alias of [web.ente.io](https://web.ente.io) | -| [auth.ente.io](https://auth.ente.io) | Production | Daily deploy of `main` | -| [accounts.ente.io](https://accounts.ente.io) | Production | Daily deploy of `main` | -| [cast.ente.io](https://cast.ente.io) | Production | Daily deploy of `main` | -| [payments.ente.io](https://payments.ente.io) | Production | Daily deploy of `main` | -| [help.ente.io](https://help.ente.io) | Production | Changes in `docs/` on push to `main` | -| [staff.ente.sh](https://staff.ente.sh) | Production | Changes in `web/apps/staff` on push to `main` | -| [accounts.ente.sh](https://accounts.ente.sh) | Preview | Daily deploy of `main` | -| [auth.ente.sh](https://auth.ente.sh) | Preview | Daily deploy of `main` | -| [cast.ente.sh](https://cast.ente.sh) | Preview | Daily deploy of `main` | -| [payments.ente.sh](https://payments.ente.sh) | Preview | Daily deploy of `main` | -| [photos.ente.sh](https://photos.ente.sh) | Preview | Daily deploy of `main` | -| [preview.ente.sh](https://preview.ente.sh) | Preview | Manually triggered | +| URL | Type | Deployment action | +| -------------------------------------------- | ---------- | ------------------------------------------- | +| [web.ente.io](https://web.ente.io) | Production | Daily deploy of `main` | +| [photos.ente.io](https://photos.ente.io) | Production | Alias of [web.ente.io](https://web.ente.io) | +| [auth.ente.io](https://auth.ente.io) | Production | Daily deploy of `main` | +| [accounts.ente.io](https://accounts.ente.io) | Production | Daily deploy of `main` | +| [cast.ente.io](https://cast.ente.io) | Production | Daily deploy of `main` | +| [payments.ente.io](https://payments.ente.io) | Production | Daily deploy of `main` | +| [help.ente.io](https://help.ente.io) | Production | Changes in `docs/` on push to `main` | +| [staff.ente.sh](https://staff.ente.sh) | Production | Changes in `infra/staff` on push to `main` | +| [accounts.ente.sh](https://accounts.ente.sh) | Preview | Daily deploy of `main` | +| [auth.ente.sh](https://auth.ente.sh) | Preview | Daily deploy of `main` | +| [cast.ente.sh](https://cast.ente.sh) | Preview | Daily deploy of `main` | +| [payments.ente.sh](https://payments.ente.sh) | Preview | Daily deploy of `main` | +| [photos.ente.sh](https://photos.ente.sh) | Preview | Daily deploy of `main` | +| [preview.ente.sh](https://preview.ente.sh) | Preview | Manually triggered | ### Other subdomains diff --git a/web/docs/storage.md b/web/docs/storage.md index f4b28bda16..0247415d98 100644 --- a/web/docs/storage.md +++ b/web/docs/storage.md @@ -4,7 +4,8 @@ Data tied to the browser tab's lifetime. -We store the user's encryption key here. +The primary information store in session storage is the user's encryption key +here. In addition, various other transient bits and bobs are also kept here. ## Local Storage diff --git a/web/docs/webauthn-passkeys.md b/web/docs/webauthn-passkeys.md index d521dc0ede..853a31145e 100644 --- a/web/docs/webauthn-passkeys.md +++ b/web/docs/webauthn-passkeys.md @@ -21,15 +21,17 @@ some operating system restrictions. ## Getting to the passkeys manager -As of Feb 2024, Ente clients have a button to navigate to a WebView of Ente +As of Jun 2024, Ente clients have a button to navigate to a WebView of Ente Accounts. Ente Accounts allows users to add and manage their registered -passkeys. +passkeys, and later authenticate with them as a second factor. -❗ Your WebView MUST invoke the operating-system's default browser, or an -equivalent browser with matching API parity. Otherwise, the user will not be -able to register or use registered WebAuthn credentials. +> [!NOTE] +> +> Your WebView MUST invoke the operating-system's default browser, or an +> equivalent browser with matching API parity. Otherwise, the user will not be +> able to register or use registered WebAuthn credentials. -### Accounts-Specific Session Token +### Ente Accounts specific session token When a user clicks this button, the client sends a request for an Accounts-specific JWT session token as shown below. **The Ente Accounts API is @@ -44,22 +46,19 @@ used.** This restriction is a byproduct of the enablement for automatic login. | ------------ | ------ | ------------------------------------------------ | | X-Auth-Token | string | The user session token. It is encoded in base64. | -##### Response Body (JSON) +##### Response body (JSON) | Key | Type | Value | | ------------- | ------ | ----------------------------------------------------------------- | | accountsToken | string | The Accounts-specific JWT session token. It is encoded in base64. | -### Automatically logging into Accounts +### Automatically logging into Ente Accounts Clients open a WebView with the URL -`https://accounts.ente.io/accounts-handoff?token=&package=`. -This page will appear like a normal loading screen to the user, but in the -background, the app parses the token and package for usage in subsequent -Accounts-related API calls. +`https://accounts.ente.io/passkeys?token=`. -If valid, the user will be automatically redirected to the passkeys management -page. Otherwise, they will be required to login with their Ente credentials. +If the token is valid, the user will be show a list of their passkeys, and they +can edit / delete them, or add new ones. ## Registering a WebAuthn credential @@ -69,10 +68,7 @@ The registration ceremony starts in the browser. When the user clicks the "Add new passkey" button, a request is sent to the server for "public key" creation options. Although named "public key" options, they actually define customizable parameters for the entire credential creation process. They're like an -instructional sheet that defines exactly what we want. As of the creation of -this document, the plan is to restrict user authenticators to cross-platform -ones, like hardware keys. Platform authenticators, such as TPM, are not portable -and are prone to loss. +instructional sheet that defines exactly what we want. On the server side, the WebAuthn library generates this information based on data provided from a `webauthn.User` interface. As a result, we satisfy this @@ -109,7 +105,7 @@ func (u *PasskeyUser) WebAuthnCredentials() []webauthn.Credential { } ``` -#### GET /passkeys/registration/begin +#### POST /passkeys/registration/begin ##### Headers @@ -117,7 +113,7 @@ func (u *PasskeyUser) WebAuthnCredentials() []webauthn.Credential { | ------------ | ------ | ------------------------------------------------ | | X-Auth-Token | string | The user session token. It is encoded in base64. | -##### Response Body (JSON) +##### Response body (JSON) | Key | Type | Value | | --------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | @@ -130,7 +126,7 @@ func (u *PasskeyUser) WebAuthnCredentials() []webauthn.Credential { "publicKey": { "rp": { "name": "Ente", - "id": "accounts.ente.io" + "id": "ente.io" }, "user": { "name": "james@example.org", @@ -203,8 +199,8 @@ We just have to decode the base64 fields back into `Uint8Array`. ```ts const options = response.options; -options.publicKey.challenge = _sodium.from_base64(options.publicKey.challenge); -options.publicKey.user.id = _sodium.from_base64(options.publicKey.user.id); +options.publicKey.challenge = sodium.from_base64(options.publicKey.challenge); +options.publicKey.user.id = sodium.from_base64(options.publicKey.user.id); ``` ### Creating the credential @@ -224,13 +220,13 @@ The browser returns the newly created credential with a bunch of binary fields, so we have to encode them into base64 for transport to the server. ```ts -const attestationObjectB64 = _sodium.to_base64( +const attestationObjectB64 = sodium.to_base64( new Uint8Array(credential.response.attestationObject), - _sodium.base64_variants.URLSAFE_NO_PADDING + sodium.base64_variants.URLSAFE_NO_PADDING ); -const clientDataJSONB64 = _sodium.to_base64( +const clientDataJSONB64 = sodium.to_base64( new Uint8Array(credential.response.clientDataJSON), - _sodium.base64_variants.URLSAFE_NO_PADDING + sodium.base64_variants.URLSAFE_NO_PADDING ``` Attestation object contains information about the nature of the credential, like @@ -281,7 +277,7 @@ credID := base64.StdEncoding.EncodeToString(cred.ID) On retrieval, this process is effectively the opposite. -#### Query Parameters +#### Query parameters | Key | Value | | ------------ | ------------------------------------------------------------------------------------------------------- | @@ -294,7 +290,7 @@ On retrieval, this process is effectively the opposite. | ------------ | ------ | ------------------------------------------------ | | X-Auth-Token | string | The user session token. It is encoded in base64. | -##### Request Body (JSON) +##### Request body (JSON) | Key | Type | Value | | -------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -305,7 +301,7 @@ On retrieval, this process is effectively the opposite. **Example** -```json +```js { id: credential.id, rawId: credential.id, @@ -337,27 +333,29 @@ if (passkeySessionID) { } ``` -The client should redirect the user to Accounts with this session ID to prompt -credential authentication. We use Accounts as the central WebAuthn hub because -credentials are locked to an FQDN. +The client should redirect the user to the Ente Accounts web app with this +session ID to prompt credential authentication. -```tsx -window.location.href = `${getAccountsURL()}/passkeys/flow?passkeySessionID=${passkeySessionID}&redirect=${ - window.location.origin -}/passkeys/finish`; ``` +https://accounts.ente.io/passkeys? + passkeySessionID=&clientPackage=& + redirect=&recover= +``` + +We use Ente Accounts as the central WebAuthn hub since it allows us to handle +mobile and desktop clients too. ### Requesting publicKey options (begin) -#### GET /users/two-factor/passkeys/begin +#### POST /users/two-factor/passkeys/begin -##### Query Parameters +##### Query parameters | Key | Value | | --------- | ------------------------------------------------------------------------- | | sessionID | The `passkeySessionID` returned from SRP login or email OTT verification. | -##### Response Body (JSON) +##### Response body (JSON) **Example** @@ -368,7 +366,7 @@ window.location.href = `${getAccountsURL()}/passkeys/flow?passkeySessionID=${pas "publicKey": { "challenge": "dF-mmdZSBxP6Z7OhZrmQ4h-k-BkuuX6ERnW_ckYdkvc", "timeout": 300000, - "rpId": "accounts.ente.io", + "rpId": "ente.io", "allowCredentials": [ { "type": "public-key", @@ -393,14 +391,14 @@ The browser requires `Uint8Array` versions of the `options` challenge and credential IDs. ```ts -publicKey.challenge = _sodium.from_base64( +publicKey.challenge = sodium.from_base64( publicKey.challenge, - _sodium.base64_variants.URLSAFE_NO_PADDING, + sodium.base64_variants.URLSAFE_NO_PADDING, ); publicKey.allowCredentials?.forEach(function (listItem: any) { - listItem.id = _sodium.from_base64( + listItem.id = sodium.from_base64( listItem.id, - _sodium.base64_variants.URLSAFE_NO_PADDING, + sodium.base64_variants.URLSAFE_NO_PADDING, ); }); ``` @@ -419,21 +417,21 @@ Before sending the public key and signature to the server, their outputs must be encoded into Base64. ```ts -authenticatorData: _sodium.to_base64( +authenticatorData: sodium.to_base64( new Uint8Array(credential.response.authenticatorData), - _sodium.base64_variants.URLSAFE_NO_PADDING + sodium.base64_variants.URLSAFE_NO_PADDING ), -clientDataJSON: _sodium.to_base64( +clientDataJSON: sodium.to_base64( new Uint8Array(credential.response.clientDataJSON), - _sodium.base64_variants.URLSAFE_NO_PADDING + sodium.base64_variants.URLSAFE_NO_PADDING ), -signature: _sodium.to_base64( +signature: sodium.to_base64( new Uint8Array(credential.response.signature), - _sodium.base64_variants.URLSAFE_NO_PADDING + sodium.base64_variants.URLSAFE_NO_PADDING ), -userHandle: _sodium.to_base64( +userHandle: sodium.to_base64( new Uint8Array(credential.response.userHandle), - _sodium.base64_variants.URLSAFE_NO_PADDING + sodium.base64_variants.URLSAFE_NO_PADDING ), ``` @@ -441,14 +439,14 @@ userHandle: _sodium.to_base64( #### POST /users/two-factor/passkeys/finish -##### Query Parameters +##### Query parameters | Key | Value | | ----------------- | ---------------------------------------------------------------------------------------- | | ceremonySessionID | The `ceremonySessionID` identifier from the begin step. | | sessionID | The `passkeySessionID` identifier from the SRP login or email OTT verification response. | -##### Request Body (JSON) +##### Request body (JSON) | Key | Type | Value | | -------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -457,7 +455,7 @@ userHandle: _sodium.to_base64( | type | string | The type of credential. | | response | object | Contains authenticatorData, clientDataJSON, signature and userHandle fields that were encoded prior to request. | -##### Response Body (JSON) +##### Response body (JSON) | Key | Type | Value | | -------------- | ------ | ------------------------------------------- | diff --git a/web/package.json b/web/package.json index ec096189ad..6278a8bd42 100644 --- a/web/package.json +++ b/web/package.json @@ -14,11 +14,6 @@ "build:payments": "yarn workspace payments build", "build:photos": "yarn workspace photos next build", "build:staff": "yarn workspace staff build", - "deploy:accounts": "open 'https://github.com/ente-io/ente/compare/deploy/accounts...main?quick_pull=1&title=[web]+Deploy+accounts&body=Deploy+accounts.ente.io'", - "deploy:auth": "open 'https://github.com/ente-io/ente/compare/deploy/auth...main?quick_pull=1&title=[web]+Deploy+auth&body=Deploy+auth.ente.io'", - "deploy:cast": "open 'https://github.com/ente-io/ente/compare/deploy/cast...main?quick_pull=1&title=[web]+Deploy+cast&body=Deploy+cast.ente.io'", - "deploy:payments": "open 'https://github.com/ente-io/ente/compare/deploy/payments...main?quick_pull=1&title=[web]+Deploy+payments&body=Deploy+payments.ente.io'", - "deploy:photos": "open 'https://github.com/ente-io/ente/compare/deploy/photos...main?quick_pull=1&title=[web]+Deploy+photos&body=Deploy+web.ente.io'", "dev": "yarn dev:photos", "dev:accounts": "yarn workspace accounts next dev -p 3001", "dev:albums": "yarn workspace photos next dev -p 3002", diff --git a/web/packages/accounts/api/user.ts b/web/packages/accounts/api/user.ts index d14402ebe1..1060bb6ebb 100644 --- a/web/packages/accounts/api/user.ts +++ b/web/packages/accounts/api/user.ts @@ -1,3 +1,4 @@ +import type { AppName } from "@/next/types/app"; import type { RecoveryKey, TwoFactorRecoveryResponse, @@ -5,7 +6,6 @@ import type { TwoFactorVerificationResponse, UserVerificationResponse, } from "@ente/accounts/types/user"; -import { APPS, OTT_CLIENTS } from "@ente/shared/apps/constants"; import type { B64EncryptionResult } from "@ente/shared/crypto/types"; import { ApiError, CustomError } from "@ente/shared/error"; import HTTPService from "@ente/shared/network/HTTPService"; @@ -13,14 +13,13 @@ import { getEndpoint } from "@ente/shared/network/api"; import { getToken } from "@ente/shared/storage/localStorage/helpers"; import type { KeyAttributes } from "@ente/shared/user/types"; import { HttpStatusCode } from "axios"; -import { TwoFactorType } from "../constants/twofactor"; const ENDPOINT = getEndpoint(); -export const sendOtt = (appName: APPS, email: string) => { +export const sendOtt = (appName: AppName, email: string) => { return HTTPService.post(`${ENDPOINT}/users/ott`, { email, - client: OTT_CLIENTS.get(appName), + client: appName == "auth" ? "totp" : "web", }); }; @@ -73,9 +72,12 @@ export const verifyTwoFactor = async (code: string, sessionID: string) => { return resp.data as UserVerificationResponse; }; +/** The type of the second factor we're trying to act on */ +export type TwoFactorType = "totp" | "passkey"; + export const recoverTwoFactor = async ( sessionID: string, - twoFactorType: TwoFactorType = TwoFactorType.TOTP, + twoFactorType: TwoFactorType, ) => { const resp = await HTTPService.get(`${ENDPOINT}/users/two-factor/recover`, { sessionID, @@ -87,7 +89,7 @@ export const recoverTwoFactor = async ( export const removeTwoFactor = async ( sessionID: string, secret: string, - twoFactorType: TwoFactorType = TwoFactorType.TOTP, + twoFactorType: TwoFactorType, ) => { const resp = await HTTPService.post(`${ENDPOINT}/users/two-factor/remove`, { sessionID, diff --git a/web/packages/accounts/components/Login.tsx b/web/packages/accounts/components/Login.tsx index 297aa28ecd..50cebd56ee 100644 --- a/web/packages/accounts/components/Login.tsx +++ b/web/packages/accounts/components/Login.tsx @@ -1,6 +1,5 @@ import log from "@/next/log"; import type { AppName } from "@/next/types/app"; -import { appNameToAppNameOld } from "@ente/shared/apps/constants"; import FormPaperFooter from "@ente/shared/components/Form/FormPaper/Footer"; import FormPaperTitle from "@ente/shared/components/Form/FormPaper/Title"; import LinkButton from "@ente/shared/components/LinkButton"; @@ -20,9 +19,7 @@ interface LoginProps { appName: AppName; } -export function Login(props: LoginProps) { - const appNameOld = appNameToAppNameOld(props.appName); - +export function Login({ appName, signUp }: LoginProps) { const router = useRouter(); const loginUser: SingleInputFormProps["callback"] = async ( @@ -34,7 +31,7 @@ export function Login(props: LoginProps) { const srpAttributes = await getSRPAttributes(email); log.debug(() => ` srpAttributes: ${JSON.stringify(srpAttributes)}`); if (!srpAttributes || srpAttributes.isEmailMFAEnabled) { - await sendOtt(appNameOld, email); + await sendOtt(appName, email); router.push(PAGES.VERIFY); } else { setData(LS_KEYS.SRP_ATTRIBUTES, srpAttributes); @@ -66,9 +63,7 @@ export function Login(props: LoginProps) { /> - - {t("NO_ACCOUNT")} - + {t("NO_ACCOUNT")} ); diff --git a/web/packages/accounts/components/SignUp.tsx b/web/packages/accounts/components/SignUp.tsx index 044eaa966f..9e0086075b 100644 --- a/web/packages/accounts/components/SignUp.tsx +++ b/web/packages/accounts/components/SignUp.tsx @@ -6,7 +6,6 @@ import { PAGES } from "@ente/accounts/constants/pages"; import { isWeakPassword } from "@ente/accounts/utils"; import { generateKeyAndSRPAttributes } from "@ente/accounts/utils/srp"; import { LS_KEYS } from "@ente/shared//storage/localStorage"; -import { appNameToAppNameOld } from "@ente/shared/apps/constants"; import { VerticallyCentered } from "@ente/shared/components/Container"; import FormPaperFooter from "@ente/shared/components/Form/FormPaper/Footer"; import FormPaperTitle from "@ente/shared/components/Form/FormPaper/Title"; @@ -57,8 +56,6 @@ interface SignUpProps { } export function SignUp({ router, appName, login }: SignUpProps) { - const appNameOld = appNameToAppNameOld(appName); - const [acceptTerms, setAcceptTerms] = useState(false); const [loading, setLoading] = useState(false); const [showPassword, setShowPassword] = useState(false); @@ -86,7 +83,7 @@ export function SignUp({ router, appName, login }: SignUpProps) { try { setData(LS_KEYS.USER, { email }); setLocalReferralSource(referral); - await sendOtt(appNameOld, email); + await sendOtt(appName, email); } catch (e) { const message = e instanceof Error ? e.message : ""; setFieldError("confirm", `${t("UNKNOWN_ERROR")} ${message}`); diff --git a/web/packages/accounts/constants/pages.ts b/web/packages/accounts/constants/pages.ts index b7658c6990..485021fd6b 100644 --- a/web/packages/accounts/constants/pages.ts +++ b/web/packages/accounts/constants/pages.ts @@ -10,6 +10,7 @@ export enum PAGES { TWO_FACTOR_SETUP = "/two-factor/setup", TWO_FACTOR_VERIFY = "/two-factor/verify", TWO_FACTOR_RECOVER = "/two-factor/recover", + // PASSKEY_RECOVER = "/passkeys/recover", VERIFY = "/verify", SHARED_ALBUMS = "/shared-albums", } diff --git a/web/packages/accounts/constants/twofactor.ts b/web/packages/accounts/constants/twofactor.ts deleted file mode 100644 index 92b10730d4..0000000000 --- a/web/packages/accounts/constants/twofactor.ts +++ /dev/null @@ -1,4 +0,0 @@ -export enum TwoFactorType { - PASSKEY = "passkey", - TOTP = "totp", -} diff --git a/web/packages/accounts/pages/change-email.tsx b/web/packages/accounts/pages/change-email.tsx index 4c46d392bc..d318fd81e6 100644 --- a/web/packages/accounts/pages/change-email.tsx +++ b/web/packages/accounts/pages/change-email.tsx @@ -1,12 +1,8 @@ +import type { AppName } from "@/next/types/app"; import { ensure } from "@/utils/ensure"; import { wait } from "@/utils/promise"; import { changeEmail, sendOTTForEmailChange } from "@ente/accounts/api/user"; import { PAGES } from "@ente/accounts/constants/pages"; -import { - APP_HOMES, - appNameToAppNameOld, - type APPS, -} from "@ente/shared/apps/constants"; import { VerticallyCentered } from "@ente/shared/components/Container"; import FormPaper from "@ente/shared/components/Form/FormPaper"; import FormPaperFooter from "@ente/shared/components/Form/FormPaper/Footer"; @@ -21,13 +17,12 @@ import { useRouter } from "next/router"; import { useEffect, useState } from "react"; import { Trans } from "react-i18next"; import * as Yup from "yup"; +import { appHomeRoute } from "../services/redirect"; import type { PageProps } from "../types/page"; const Page: React.FC = ({ appContext }) => { const { appName } = appContext; - const appNameOld = appNameToAppNameOld(appName); - const router = useRouter(); useEffect(() => { @@ -41,7 +36,7 @@ const Page: React.FC = ({ appContext }) => { {t("CHANGE_EMAIL")} - + ); @@ -54,7 +49,11 @@ interface formValues { ott?: string; } -function ChangeEmailForm({ appName }: { appName: APPS }) { +interface ChangeEmailFormProps { + appName: AppName; +} + +const ChangeEmailForm: React.FC = ({ appName }) => { const [loading, setLoading] = useState(false); const [ottInputVisible, setShowOttInputVisibility] = useState(false); const [email, setEmail] = useState(null); @@ -102,10 +101,7 @@ function ChangeEmailForm({ appName }: { appName: APPS }) { } }; - const goToApp = () => { - // TODO: Refactor the type of APP_HOMES to not require the ?? - router.push(APP_HOMES.get(appName) ?? "/"); - }; + const goToApp = () => router.push(appHomeRoute(appName)); return ( @@ -212,4 +208,4 @@ function ChangeEmailForm({ appName }: { appName: APPS }) { )} ); -} +}; diff --git a/web/packages/accounts/pages/change-password.tsx b/web/packages/accounts/pages/change-password.tsx index 2ecd09fd03..7135caa3bd 100644 --- a/web/packages/accounts/pages/change-password.tsx +++ b/web/packages/accounts/pages/change-password.tsx @@ -13,7 +13,6 @@ import { convertBase64ToBuffer, convertBufferToBase64, } from "@ente/accounts/utils"; -import { APP_HOMES, appNameToAppNameOld } from "@ente/shared/apps/constants"; import { VerticallyCentered } from "@ente/shared/components/Container"; import FormPaper from "@ente/shared/components/Form/FormPaper"; import FormPaperFooter from "@ente/shared/components/Form/FormPaper/Footer"; @@ -33,13 +32,12 @@ import type { KEK, KeyAttributes, User } from "@ente/shared/user/types"; import { t } from "i18next"; import { useRouter } from "next/router"; import { useEffect, useState } from "react"; +import { appHomeRoute } from "../services/redirect"; import type { PageProps } from "../types/page"; const Page: React.FC = ({ appContext }) => { const { appName } = appContext; - const appNameOld = appNameToAppNameOld(appName); - const [token, setToken] = useState(); const [user, setUser] = useState(); @@ -126,8 +124,7 @@ const Page: React.FC = ({ appContext }) => { const redirectToAppHome = () => { setData(LS_KEYS.SHOW_BACK_BUTTON, { value: true }); - // TODO: Refactor the type of APP_HOMES to not require the ?? - router.push(APP_HOMES.get(appNameOld) ?? "/"); + router.push(appHomeRoute(appName)); }; // TODO: Handle the case where user is not loaded yet. diff --git a/web/packages/accounts/pages/credentials.tsx b/web/packages/accounts/pages/credentials.tsx index 85a1b2fe73..b44bc4e103 100644 --- a/web/packages/accounts/pages/credentials.tsx +++ b/web/packages/accounts/pages/credentials.tsx @@ -1,12 +1,16 @@ import { isDevBuild } from "@/next/env"; import log from "@/next/log"; import { ensure } from "@/utils/ensure"; -import { APP_HOMES, appNameToAppNameOld } from "@ente/shared/apps/constants"; import { VerticallyCentered } from "@ente/shared/components/Container"; import EnteSpinner from "@ente/shared/components/EnteSpinner"; import FormPaper from "@ente/shared/components/Form/FormPaper"; import FormPaperFooter from "@ente/shared/components/Form/FormPaper/Footer"; import LinkButton from "@ente/shared/components/LinkButton"; +import { + ConnectionDetails, + PasswordHeader, + VerifyingPasskey, +} from "@ente/shared/components/LoginComponents"; import VerifyMasterPasswordForm, { type VerifyMasterPasswordFormProps, } from "@ente/shared/components/VerifyMasterPasswordForm"; @@ -19,7 +23,6 @@ import { } from "@ente/shared/crypto/helpers"; import type { B64EncryptionResult } from "@ente/shared/crypto/types"; import { CustomError } from "@ente/shared/error"; -import { getAccountsURL, getEndpoint } from "@ente/shared/network/api"; import InMemoryStore, { MS_KEYS } from "@ente/shared/storage/InMemoryStore"; import { LS_KEYS, @@ -39,12 +42,16 @@ import { setKey, } from "@ente/shared/storage/sessionStorage"; import type { KeyAttributes, User } from "@ente/shared/user/types"; -import { Typography, styled } from "@mui/material"; import { t } from "i18next"; import { useRouter } from "next/router"; import { useEffect, useState } from "react"; import { getSRPAttributes } from "../api/srp"; import { PAGES } from "../constants/pages"; +import { + openPasskeyVerificationURL, + passkeyVerificationRedirectURL, +} from "../services/passkey"; +import { appHomeRoute } from "../services/redirect"; import { configureSRP, generateSRPSetupAttributes, @@ -56,13 +63,15 @@ import type { SRPAttributes } from "../types/srp"; const Page: React.FC = ({ appContext }) => { const { appName, logout } = appContext; - const appNameOld = appNameToAppNameOld(appName); - const [srpAttributes, setSrpAttributes] = useState(); const [keyAttributes, setKeyAttributes] = useState(); const [user, setUser] = useState(); + const [passkeyVerificationData, setPasskeyVerificationData] = useState< + { passkeySessionID: string; url: string } | undefined + >(); const router = useRouter(); + useEffect(() => { const main = async () => { const user: User = getData(LS_KEYS.USER); @@ -89,8 +98,7 @@ const Page: React.FC = ({ appContext }) => { } const token = getToken(); if (key && token) { - // TODO: Refactor the type of APP_HOMES to not require the ?? - router.push(APP_HOMES.get(appNameOld) ?? "/"); + router.push(appHomeRoute(appName)); return; } const kekEncryptedAttributes: B64EncryptionResult = getKey( @@ -169,10 +177,13 @@ const Page: React.FC = ({ appContext }) => { isTwoFactorPasskeysEnabled: true, }); InMemoryStore.set(MS_KEYS.REDIRECT_URL, PAGES.ROOT); - window.location.href = `${getAccountsURL()}/passkeys/flow?passkeySessionID=${passkeySessionID}&redirect=${ - window.location.origin - }/passkeys/finish`; - return undefined; + const url = passkeyVerificationRedirectURL( + appName, + passkeySessionID, + ); + setPasskeyVerificationData({ passkeySessionID, url }); + openPasskeyVerificationURL({ passkeySessionID, url }); + throw Error(CustomError.TWO_FACTOR_ENABLED); } else if (twoFactorSessionID) { const sessionKeyAttributes = await cryptoWorker.generateKeyAndEncryptToB64(kek); @@ -250,14 +261,12 @@ const Page: React.FC = ({ appContext }) => { } const redirectURL = InMemoryStore.get(MS_KEYS.REDIRECT_URL); InMemoryStore.delete(MS_KEYS.REDIRECT_URL); - router.push(redirectURL ?? APP_HOMES.get(appNameOld)); + router.push(redirectURL ?? appHomeRoute(appName)); } catch (e) { log.error("useMasterPassword failed", e); } }; - const redirectToRecoverPage = () => router.push(PAGES.RECOVER); - if (!keyAttributes && !srpAttributes) { return ( @@ -266,12 +275,42 @@ const Page: React.FC = ({ appContext }) => { ); } + if (passkeyVerificationData) { + // We only need to handle this scenario when running in the desktop app + // because the web app will navigate to Passkey verification URL. + // However, still we add an additional `globalThis.electron` check to + // show a spinner. This prevents the VerifyingPasskey component from + // being disorientingly shown for a fraction of a second as the redirect + // happens on the web app. + // + // See: [Note: Passkey verification in the desktop app] + + if (!globalThis.electron) { + return ( + + + + ); + } + + return ( + + openPasskeyVerificationURL(passkeyVerificationData) + } + appContext={appContext} + /> + ); + } + // TODO: Handle the case when user is not present, or exclude that // possibility using types. return ( -
{user?.email ?? ""}
+ {user?.email ?? ""} = ({ appContext }) => { /> - + router.push(PAGES.RECOVER)}> {t("FORGOT_PASSWORD")} @@ -298,35 +337,3 @@ const Page: React.FC = ({ appContext }) => { }; export default Page; - -const Header: React.FC = ({ children }) => { - return ( - - {t("PASSWORD")} - {children} - - ); -}; - -const Header_ = styled("div")` - margin-block-end: 4rem; - display: flex; - flex-direction: column; - gap: 8px; -`; - -const ConnectionDetails: React.FC = () => { - const apiOrigin = new URL(getEndpoint()); - - return ( - - - {apiOrigin.host} - - - ); -}; - -const ConnectionDetails_ = styled("div")` - margin-block-start: 1rem; -`; diff --git a/web/packages/accounts/pages/generate.tsx b/web/packages/accounts/pages/generate.tsx index 3ce7293f6e..ff4085b67d 100644 --- a/web/packages/accounts/pages/generate.tsx +++ b/web/packages/accounts/pages/generate.tsx @@ -7,7 +7,6 @@ import SetPasswordForm, { import { PAGES } from "@ente/accounts/constants/pages"; import { configureSRP } from "@ente/accounts/services/srp"; import { generateKeyAndSRPAttributes } from "@ente/accounts/utils/srp"; -import { APP_HOMES, appNameToAppNameOld } from "@ente/shared/apps/constants"; import { VerticallyCentered } from "@ente/shared/components/Container"; import EnteSpinner from "@ente/shared/components/EnteSpinner"; import FormPaper from "@ente/shared/components/Form/FormPaper"; @@ -29,13 +28,12 @@ import type { KeyAttributes, User } from "@ente/shared/user/types"; import { t } from "i18next"; import { useRouter } from "next/router"; import { useEffect, useState } from "react"; +import { appHomeRoute } from "../services/redirect"; import type { PageProps } from "../types/page"; const Page: React.FC = ({ appContext }) => { const { appName, logout } = appContext; - const appNameOld = appNameToAppNameOld(appName); - const [token, setToken] = useState(); const [user, setUser] = useState(); const [recoverModalView, setRecoveryModalView] = useState(false); @@ -58,8 +56,7 @@ const Page: React.FC = ({ appContext }) => { setRecoveryModalView(true); setLoading(false); } else { - // TODO: Refactor the type of APP_HOMES to not require the ?? - router.push(APP_HOMES.get(appNameOld) ?? "/"); + router.push(appHomeRoute(appName)); } } else if (keyAttributes?.encryptedKey) { router.push(PAGES.CREDENTIALS); @@ -109,8 +106,7 @@ const Page: React.FC = ({ appContext }) => { show={recoverModalView} onHide={() => { setRecoveryModalView(false); - // TODO: Refactor the type of APP_HOMES to not require the ?? - router.push(APP_HOMES.get(appNameOld) ?? "/"); + router.push(appHomeRoute(appName)); }} /* TODO: Why is this error being ignored */ somethingWentWrong={() => {}} diff --git a/web/packages/accounts/pages/passkeys/finish.tsx b/web/packages/accounts/pages/passkeys/finish.tsx index b9b5b2cd40..be52cc02ae 100644 --- a/web/packages/accounts/pages/passkeys/finish.tsx +++ b/web/packages/accounts/pages/passkeys/finish.tsx @@ -1,39 +1,37 @@ -import { PAGES } from "@ente/accounts/constants/pages"; +import log from "@/next/log"; +import { nullToUndefined } from "@/utils/transform"; import { VerticallyCentered } from "@ente/shared/components/Container"; import EnteSpinner from "@ente/shared/components/EnteSpinner"; +import { fromB64URLSafeNoPaddingString } from "@ente/shared/crypto/internal/libsodium"; import InMemoryStore, { MS_KEYS } from "@ente/shared/storage/InMemoryStore"; import { LS_KEYS, getData, setData } from "@ente/shared/storage/localStorage"; import { useRouter } from "next/router"; import React, { useEffect } from "react"; +import { PAGES } from "../../constants/pages"; +import type { PageProps } from "../../types/page"; -const Page: React.FC = () => { +/** + * [Note: Finish passkey flow in the requesting app] + * + * The passkey finish step needs to happen in the context of the client which + * invoked the passkey flow since it needs to save the obtained credentials + * in local storage (which is tied to the current origin). + */ +const Page: React.FC = () => { const router = useRouter(); - const init = async () => { - // get response from query params - const searchParams = new URLSearchParams(window.location.search); - const response = searchParams.get("response"); - - if (!response) return; - - // decode response - const decodedResponse = JSON.parse(atob(response)); - - const { keyAttributes, encryptedToken, token, id } = decodedResponse; - setData(LS_KEYS.USER, { - ...getData(LS_KEYS.USER), - token, - encryptedToken, - id, - }); - setData(LS_KEYS.KEY_ATTRIBUTES, keyAttributes); - const redirectURL = InMemoryStore.get(MS_KEYS.REDIRECT_URL); - InMemoryStore.delete(MS_KEYS.REDIRECT_URL); - router.push(redirectURL ?? PAGES.ROOT); - }; - useEffect(() => { - init(); + // Extract response from query params + const searchParams = new URLSearchParams(window.location.search); + const passkeySessionID = searchParams.get("passkeySessionID"); + const response = searchParams.get("response"); + if (!passkeySessionID || !response) return; + + saveCredentialsAndNavigateTo(passkeySessionID, response).then( + (slug: string) => { + router.push(slug); + }, + ); }, []); return ( @@ -44,3 +42,72 @@ const Page: React.FC = () => { }; export default Page; + +/** + * Extract credentials from a successful passkey flow "response" query parameter + * and save them to local storage for use by subsequent steps (or normal + * functioning) of the app. + * + * @param passkeySessionID The string that is passed as the "passkeySessionID" + * query parameter to us. + * + * @param response The string that is passed as the "response" query parameter to + * us (we're the final "finish" page in the passkey flow). + * + * @returns the slug that we should navigate to now. + */ +const saveCredentialsAndNavigateTo = async ( + passkeySessionID: string, + response: string, +) => { + const inflightPasskeySessionID = nullToUndefined( + sessionStorage.getItem("inflightPasskeySessionID"), + ); + if ( + !inflightPasskeySessionID || + passkeySessionID != inflightPasskeySessionID + ) { + // This is not the princess we were looking for. However, we have + // already entered this castle. Redirect back to home without changing + // any state, hopefully this will get the user back to where they were. + log.info( + `Ignoring redirect for unexpected passkeySessionID ${passkeySessionID}`, + ); + return "/"; + } + + sessionStorage.removeItem("inflightPasskeySessionID"); + + // Decode response string (inverse of the steps we perform in + // `passkeyAuthenticationSuccessRedirectURL`). + const decodedResponse = JSON.parse( + await fromB64URLSafeNoPaddingString(response), + ); + + // Only one of `encryptedToken` or `token` will be present depending on the + // account's lifetime: + // + // - The plaintext "token" will be passed during fresh signups, where we + // don't yet have keys to encrypt it, the account itself is being created + // as we go through this flow. + // TODO(MR): Conceptually this cannot happen. During a _real_ fresh signup + // we'll never enter the passkey verification flow. Remove this code after + // making sure that it doesn't get triggered in cases where an existing + // user goes through the new user flow. + // + // - The encrypted `encryptedToken` will be present otherwise (i.e. if the + // user is signing into an existing account). + const { keyAttributes, encryptedToken, token, id } = decodedResponse; + + setData(LS_KEYS.USER, { + ...getData(LS_KEYS.USER), + token, + encryptedToken, + id, + }); + setData(LS_KEYS.KEY_ATTRIBUTES, keyAttributes); + + const redirectURL = InMemoryStore.get(MS_KEYS.REDIRECT_URL); + InMemoryStore.delete(MS_KEYS.REDIRECT_URL); + return redirectURL ?? PAGES.CREDENTIALS; +}; diff --git a/web/packages/accounts/pages/passkeys/recover.tsx b/web/packages/accounts/pages/passkeys/recover.tsx new file mode 100644 index 0000000000..d36df9c557 --- /dev/null +++ b/web/packages/accounts/pages/passkeys/recover.tsx @@ -0,0 +1,9 @@ +import React from "react"; +import type { PageProps } from "../../types/page"; +import TwoFactorRecoverPage from "../two-factor/recover"; + +const Page: React.FC = ({ appContext }) => ( + +); + +export default Page; diff --git a/web/packages/accounts/pages/recover.tsx b/web/packages/accounts/pages/recover.tsx index 1e8b99895b..b6120697d9 100644 --- a/web/packages/accounts/pages/recover.tsx +++ b/web/packages/accounts/pages/recover.tsx @@ -2,7 +2,6 @@ import log from "@/next/log"; import { ensure } from "@/utils/ensure"; import { sendOtt } from "@ente/accounts/api/user"; import { PAGES } from "@ente/accounts/constants/pages"; -import { APP_HOMES, appNameToAppNameOld } from "@ente/shared/apps/constants"; import { VerticallyCentered } from "@ente/shared/components/Container"; import FormPaper from "@ente/shared/components/Form/FormPaper"; import FormPaperFooter from "@ente/shared/components/Form/FormPaper/Footer"; @@ -23,6 +22,7 @@ import type { KeyAttributes, User } from "@ente/shared/user/types"; import { t } from "i18next"; import { useRouter } from "next/router"; import { useEffect, useState } from "react"; +import { appHomeRoute } from "../services/redirect"; import type { PageProps } from "../types/page"; const bip39 = require("bip39"); @@ -32,8 +32,6 @@ bip39.setDefaultWordlist("english"); const Page: React.FC = ({ appContext }) => { const { appName } = appContext; - const appNameOld = appNameToAppNameOld(appName); - const [keyAttributes, setKeyAttributes] = useState< KeyAttributes | undefined >(); @@ -49,7 +47,7 @@ const Page: React.FC = ({ appContext }) => { return; } if (!user?.encryptedToken && !user?.token) { - sendOtt(appNameOld, user.email); + sendOtt(appName, user.email); InMemoryStore.set(MS_KEYS.REDIRECT_URL, PAGES.RECOVER); router.push(PAGES.VERIFY); return; @@ -57,8 +55,7 @@ const Page: React.FC = ({ appContext }) => { if (!keyAttributes) { router.push(PAGES.GENERATE); } else if (key) { - // TODO: Refactor the type of APP_HOMES to not require the ?? - router.push(APP_HOMES.get(appNameOld) ?? "/"); + router.push(appHomeRoute(appName)); } else { setKeyAttributes(keyAttributes); } diff --git a/web/packages/accounts/pages/two-factor/recover.tsx b/web/packages/accounts/pages/two-factor/recover.tsx index 8ea9d7ea30..22608c607f 100644 --- a/web/packages/accounts/pages/two-factor/recover.tsx +++ b/web/packages/accounts/pages/two-factor/recover.tsx @@ -1,10 +1,12 @@ import log from "@/next/log"; import type { BaseAppContextT } from "@/next/types/app"; import { ensure } from "@/utils/ensure"; -import { recoverTwoFactor, removeTwoFactor } from "@ente/accounts/api/user"; +import { + recoverTwoFactor, + removeTwoFactor, + type TwoFactorType, +} from "@ente/accounts/api/user"; import { PAGES } from "@ente/accounts/constants/pages"; -import { TwoFactorType } from "@ente/accounts/constants/twofactor"; -import { APPS } from "@ente/shared/apps/constants"; import { VerticallyCentered } from "@ente/shared/components/Container"; import type { DialogBoxAttributesV2 } from "@ente/shared/components/DialogBoxV2/types"; import FormPaper from "@ente/shared/components/Form/FormPaper"; @@ -32,14 +34,10 @@ bip39.setDefaultWordlist("english"); export interface RecoverPageProps { appContext: BaseAppContextT; - appName?: APPS; - twoFactorType?: TwoFactorType; + twoFactorType: TwoFactorType; } -const Page: React.FC = ({ - appContext, - twoFactorType = TwoFactorType.TOTP, -}) => { +const Page: React.FC = ({ appContext, twoFactorType }) => { const { logout } = appContext; const [encryptedTwoFactorSecret, setEncryptedTwoFactorSecret] = @@ -52,22 +50,20 @@ const Page: React.FC = ({ useEffect(() => { const user = getData(LS_KEYS.USER); - if (!user || !user.email || !user.twoFactorSessionID) { + const sid = user.passkeySessionID || user.twoFactorSessionID; + if (!user || !user.email || !sid) { router.push(PAGES.ROOT); } else if ( - !user.isTwoFactorEnabled && + !(user.isTwoFactorEnabled || user.isTwoFactorEnabledPasskey) && (user.encryptedToken || user.token) ) { router.push(PAGES.GENERATE); } else { - setSessionID(user.twoFactorSessionID); + setSessionID(sid); } const main = async () => { try { - const resp = await recoverTwoFactor( - user.twoFactorSessionID, - twoFactorType, - ); + const resp = await recoverTwoFactor(sid, twoFactorType); setDoesHaveEncryptedRecoveryKey(!!resp.encryptedSecret); if (!resp.encryptedSecret) { showContactSupportDialog({ diff --git a/web/packages/accounts/pages/two-factor/setup.tsx b/web/packages/accounts/pages/two-factor/setup.tsx index 98887fcf04..6bb8613a5c 100644 --- a/web/packages/accounts/pages/two-factor/setup.tsx +++ b/web/packages/accounts/pages/two-factor/setup.tsx @@ -6,7 +6,6 @@ import VerifyTwoFactor, { } from "@ente/accounts/components/two-factor/VerifyForm"; import { TwoFactorSetup } from "@ente/accounts/components/two-factor/setup"; import type { TwoFactorSecret } from "@ente/accounts/types/user"; -import { APP_HOMES, appNameToAppNameOld } from "@ente/shared/apps/constants"; import { VerticallyCentered } from "@ente/shared/components/Container"; import LinkButton from "@ente/shared/components/LinkButton"; import { encryptWithRecoveryKey } from "@ente/shared/crypto/helpers"; @@ -16,6 +15,7 @@ import Card from "@mui/material/Card"; import { t } from "i18next"; import { useRouter } from "next/router"; import { useEffect, useState } from "react"; +import { appHomeRoute } from "../../services/redirect"; import type { PageProps } from "../../types/page"; export enum SetupMode { @@ -26,8 +26,6 @@ export enum SetupMode { const Page: React.FC = ({ appContext }) => { const { appName } = appContext; - const appNameOld = appNameToAppNameOld(appName); - const [twoFactorSecret, setTwoFactorSecret] = useState< TwoFactorSecret | undefined >(); @@ -62,8 +60,7 @@ const Page: React.FC = ({ appContext }) => { ...getData(LS_KEYS.USER), isTwoFactorEnabled: true, }); - // TODO: Refactor the type of APP_HOMES to not require the ?? - router.push(APP_HOMES.get(appNameOld) ?? "/"); + router.push(appHomeRoute(appName)); }; return ( diff --git a/web/packages/accounts/pages/verify.tsx b/web/packages/accounts/pages/verify.tsx index 01e6e5884d..c6e6954d12 100644 --- a/web/packages/accounts/pages/verify.tsx +++ b/web/packages/accounts/pages/verify.tsx @@ -1,17 +1,16 @@ import { ensure } from "@/utils/ensure"; import type { UserVerificationResponse } from "@ente/accounts/types/user"; -import { appNameToAppNameOld } from "@ente/shared/apps/constants"; import { VerticallyCentered } from "@ente/shared/components/Container"; import EnteSpinner from "@ente/shared/components/EnteSpinner"; import FormPaper from "@ente/shared/components/Form/FormPaper"; import FormPaperFooter from "@ente/shared/components/Form/FormPaper/Footer"; import FormPaperTitle from "@ente/shared/components/Form/FormPaper/Title"; import LinkButton from "@ente/shared/components/LinkButton"; +import { VerifyingPasskey } from "@ente/shared/components/LoginComponents"; import SingleInputForm, { type SingleInputFormProps, } from "@ente/shared/components/SingleInputForm"; import { ApiError } from "@ente/shared/error"; -import { getAccountsURL } from "@ente/shared/network/api"; import InMemoryStore, { MS_KEYS } from "@ente/shared/storage/InMemoryStore"; import localForage from "@ente/shared/storage/localForage"; import { LS_KEYS, getData, setData } from "@ente/shared/storage/localStorage"; @@ -29,6 +28,10 @@ import { useEffect, useState } from "react"; import { Trans } from "react-i18next"; import { putAttributes, sendOtt, verifyOtt } from "../api/user"; import { PAGES } from "../constants/pages"; +import { + openPasskeyVerificationURL, + passkeyVerificationRedirectURL, +} from "../services/passkey"; import { configureSRP } from "../services/srp"; import type { PageProps } from "../types/page"; import type { SRPSetupAttributes } from "../types/srp"; @@ -36,10 +39,11 @@ import type { SRPSetupAttributes } from "../types/srp"; const Page: React.FC = ({ appContext }) => { const { appName, logout } = appContext; - const appNameOld = appNameToAppNameOld(appName); - const [email, setEmail] = useState(""); const [resend, setResend] = useState(0); + const [passkeyVerificationData, setPasskeyVerificationData] = useState< + { passkeySessionID: string; url: string } | undefined + >(); const router = useRouter(); @@ -87,11 +91,15 @@ const Page: React.FC = ({ appContext }) => { isTwoFactorEnabled: true, isTwoFactorPasskeysEnabled: true, }); + // TODO: This is not the first login though if they already have + // 2FA. Does this flag mean first login on this device? setIsFirstLogin(true); - window.location.href = `${getAccountsURL()}/passkeys/flow?passkeySessionID=${passkeySessionID}&redirect=${ - window.location.origin - }/passkeys/finish`; - router.push(PAGES.CREDENTIALS); + const url = passkeyVerificationRedirectURL( + appName, + passkeySessionID, + ); + setPasskeyVerificationData({ passkeySessionID, url }); + openPasskeyVerificationURL({ passkeySessionID, url }); } else if (twoFactorSessionID) { setData(LS_KEYS.USER, { email, @@ -151,7 +159,7 @@ const Page: React.FC = ({ appContext }) => { const resendEmail = async () => { setResend(1); - await sendOtt(appNameOld, email); + await sendOtt(appName, email); setResend(2); setTimeout(() => setResend(0), 3000); }; @@ -164,6 +172,36 @@ const Page: React.FC = ({ appContext }) => { ); } + if (passkeyVerificationData) { + // We only need to handle this scenario when running in the desktop app + // because the web app will navigate to Passkey verification URL. + // However, still we add an additional `globalThis.electron` check to + // show a spinner. This prevents the VerifyingPasskey component from + // being disorientingly shown for a fraction of a second as the redirect + // happens on the web app. + // + // See: [Note: Passkey verification in the desktop app] + + if (!globalThis.electron) { + return ( + + + + ); + } + + return ( + + openPasskeyVerificationURL(passkeyVerificationData) + } + appContext={appContext} + /> + ); + } + return ( diff --git a/web/packages/accounts/services/logout.ts b/web/packages/accounts/services/logout.ts index 7a150384db..59371e1e7c 100644 --- a/web/packages/accounts/services/logout.ts +++ b/web/packages/accounts/services/logout.ts @@ -1,4 +1,5 @@ import { clearBlobCaches } from "@/next/blob-cache"; +import { clearHTTPState } from "@/next/http"; import log from "@/next/log"; import InMemoryStore from "@ente/shared/storage/InMemoryStore"; import localForage from "@ente/shared/storage/localForage"; @@ -50,4 +51,9 @@ export const accountLogout = async () => { } catch (e) { ignoreError("cache", e); } + try { + clearHTTPState(); + } catch (e) { + ignoreError("http", e); + } }; diff --git a/web/packages/accounts/services/passkey.ts b/web/packages/accounts/services/passkey.ts index 809517791f..f16ceeaae9 100644 --- a/web/packages/accounts/services/passkey.ts +++ b/web/packages/accounts/services/passkey.ts @@ -1,15 +1,145 @@ +import { clientPackageHeaderIfPresent } from "@/next/http"; import log from "@/next/log"; +import type { AppName } from "@/next/types/app"; +import { clientPackageName } from "@/next/types/app"; +import { TwoFactorAuthorizationResponse } from "@/next/types/credentials"; +import { ensure } from "@/utils/ensure"; +import ComlinkCryptoWorker from "@ente/shared/crypto"; +import { getRecoveryKey } from "@ente/shared/crypto/helpers"; +import { + encryptToB64, + generateEncryptionKey, +} from "@ente/shared/crypto/internal/libsodium"; import { CustomError } from "@ente/shared/error"; import HTTPService from "@ente/shared/network/HTTPService"; -import { getEndpoint } from "@ente/shared/network/api"; +import { accountsAppURL, apiOrigin } from "@ente/shared/network/api"; +import InMemoryStore, { MS_KEYS } from "@ente/shared/storage/InMemoryStore"; +import { LS_KEYS, getData, setData } from "@ente/shared/storage/localStorage"; import { getToken } from "@ente/shared/storage/localStorage/helpers"; +/** + * Construct a redirect URL to take the user to Ente accounts app to + * authenticate using their second factor, a passkey they've configured. + * + * On successful verification, the accounts app will redirect back to our + * `/passkeys/finish` page. + * + * @param appName The {@link AppName} of the app which is calling this function. + * + * @param passkeySessionID An identifier provided by museum for this passkey + * verification session. + */ +export const passkeyVerificationRedirectURL = ( + appName: AppName, + passkeySessionID: string, +) => { + const clientPackage = clientPackageName(appName); + // Using `window.location.origin` will work both when we're running in a web + // browser, and in our desktop app. See: [Note: Using deeplinks to navigate + // in desktop app] + const redirect = `${window.location.origin}/passkeys/finish`; + // See: [Note: Conditional passkey recover option on accounts] + const recoverOption: Record = globalThis.electron + ? {} + : { recover: `${window.location.origin}/passkeys/recover` }; + const params = new URLSearchParams({ + clientPackage, + passkeySessionID, + redirect, + ...recoverOption, + }); + return `${accountsAppURL()}/passkeys/verify?${params.toString()}`; +}; + +interface OpenPasskeyVerificationURLOptions { + /** + * The passkeySessionID for which we are redirecting. + * + * This is compared to the saved session id in the browser's session storage + * to allow us to ignore redirects to the passkey flow finish page except + * the ones for this specific session we're awaiting. + */ + passkeySessionID: string; + /** The URL to redirect to or open in the system browser. */ + url: string; +} + +/** + * Open or redirect to a passkey verification URL previously constructed using + * {@link passkeyVerificationRedirectURL}. + * + * [Note: Passkey verification in the desktop app] + * + * Our desktop app bundles the web app and serves it over a custom protocol. + * Passkeys are tied to origins, and will not work with this custom protocol + * even if we move the passkey creation and authentication inline to within the + * Photos web app. + * + * Thus, passkey creation and authentication in the desktop app works the same + * way it works in the mobile app - the system browser is invoked to open + * accounts.ente.io. + * + * - For passkey creation, this is a one-way open. Passkeys get created at + * accounts.ente.io, and that's it. + * + * - For passkey verification, the flow is two-way. We register a custom + * protocol and provide that as a return path redirect. Passkey + * authentication happens at accounts.ente.io, and on success there is + * redirected back to the desktop app. + */ +export const openPasskeyVerificationURL = ({ + passkeySessionID, + url, +}: OpenPasskeyVerificationURLOptions) => { + sessionStorage.setItem("inflightPasskeySessionID", passkeySessionID); + + if (globalThis.electron) window.open(url); + else window.location.href = url; +}; + +/** + * Open a new window showing a page on the Ente accounts app where the user can + * see and their manage their passkeys. + * + * @param appName The {@link AppName} of the app which is calling this function. + */ +export const openAccountsManagePasskeysPage = async () => { + // Check if the user has passkey recovery enabled + const recoveryEnabled = await isPasskeyRecoveryEnabled(); + if (!recoveryEnabled) { + // If not, enable it for them by creating the necessary recovery + // information to prevent them from getting locked out. + const recoveryKey = await getRecoveryKey(); + + const resetSecret = await generateEncryptionKey(); + + const cryptoWorker = await ComlinkCryptoWorker.getInstance(); + const encryptionResult = await encryptToB64( + resetSecret, + await cryptoWorker.fromHex(recoveryKey), + ); + + await configurePasskeyRecovery( + resetSecret, + encryptionResult.encryptedData, + encryptionResult.nonce, + ); + } + + // Redirect to the Ente Accounts app where they can view and add and manage + // their passkeys. + const token = await getAccountsToken(); + const params = new URLSearchParams({ token }); + + window.open(`${accountsAppURL()}/passkeys?${params.toString()}`); +}; + export const isPasskeyRecoveryEnabled = async () => { try { const token = getToken(); const resp = await HTTPService.get( - `${getEndpoint()}/users/two-factor/recovery-status`, + `${apiOrigin()}/users/two-factor/recovery-status`, {}, { "X-Auth-Token": token, @@ -27,7 +157,7 @@ export const isPasskeyRecoveryEnabled = async () => { } }; -export const configurePasskeyRecovery = async ( +const configurePasskeyRecovery = async ( secret: string, userSecretCipher: string, userSecretNonce: string, @@ -36,7 +166,7 @@ export const configurePasskeyRecovery = async ( const token = getToken(); const resp = await HTTPService.post( - `${getEndpoint()}/users/two-factor/passkeys/configure-recovery`, + `${apiOrigin()}/users/two-factor/passkeys/configure-recovery`, { secret, userSecretCipher, @@ -56,3 +186,96 @@ export const configurePasskeyRecovery = async ( throw e; } }; + +/** + * Fetch an Ente Accounts specific JWT token. + * + * This token can be used to authenticate with the Ente accounts app. + */ +const getAccountsToken = async () => { + const token = getToken(); + + const resp = await HTTPService.get( + `${apiOrigin()}/users/accounts-token`, + undefined, + { + "X-Auth-Token": token, + }, + ); + return resp.data["accountsToken"]; +}; + +/** + * The passkey session whose status we are trying to check has already expired. + * The user should attempt to login again. + */ +export const passkeySessionExpiredErrorMessage = "Passkey session has expired"; + +/** + * Check if the user has already authenticated using their passkey for the given + * session. + * + * This is useful in case the automatic redirect back from accounts.ente.io to + * the desktop app does not work for some reason. In such cases, the user can + * press the "Check status" button: we'll make an API call to see if the + * authentication has already completed, and if so, get the same "response" + * object we'd have gotten as a query parameter in a redirect in + * {@link saveCredentialsAndNavigateTo} on the "/passkeys/finish" page. + * + * @param sessionID The passkey session whose session we wish to check the + * status of. + * + * @returns A {@link TwoFactorAuthorizationResponse} if the passkey + * authentication has completed, and `undefined` otherwise. + * + * @throws In addition to arbitrary errors, it throws errors with the message + * {@link passkeySessionExpiredErrorMessage}. + */ +export const checkPasskeyVerificationStatus = async ( + sessionID: string, +): Promise => { + const url = `${apiOrigin()}/users/two-factor/passkeys/get-token`; + const params = new URLSearchParams({ sessionID }); + const res = await fetch(`${url}?${params.toString()}`, { + headers: clientPackageHeaderIfPresent(), + }); + if (!res.ok) { + if (res.status == 404 || res.status == 410) + throw new Error(passkeySessionExpiredErrorMessage); + if (res.status == 400) return undefined; /* verification pending */ + throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`); + } + return TwoFactorAuthorizationResponse.parse(await res.json()); +}; + +/** + * Extract credentials from a successful passkey verification response and save + * them to local storage for use by subsequent steps (or normal functioning) of + * the app. + * + * @param response The result of a successful + * {@link checkPasskeyVerificationStatus}. + * + * @returns the slug that we should navigate to now. + */ +export const saveCredentialsAndNavigateTo = ( + response: TwoFactorAuthorizationResponse, +) => { + // This method somewhat duplicates `saveCredentialsAndNavigateTo` in the + // /passkeys/finish page. + const { id, encryptedToken, keyAttributes } = response; + + setData(LS_KEYS.USER, { + ...getData(LS_KEYS.USER), + encryptedToken, + id, + }); + setData(LS_KEYS.KEY_ATTRIBUTES, ensure(keyAttributes)); + + // TODO(MR): Remove the cast. + const redirectURL = InMemoryStore.get(MS_KEYS.REDIRECT_URL) as + | string + | undefined; + InMemoryStore.delete(MS_KEYS.REDIRECT_URL); + return redirectURL ?? "/credentials"; +}; diff --git a/web/packages/accounts/services/redirect.ts b/web/packages/accounts/services/redirect.ts index 084d001ec5..b4b0322335 100644 --- a/web/packages/accounts/services/redirect.ts +++ b/web/packages/accounts/services/redirect.ts @@ -1,21 +1,15 @@ import type { AppName } from "@/next/types/app"; -import { - ACCOUNTS_PAGES, - AUTH_PAGES, - PHOTOS_PAGES, -} from "@ente/shared/constants/pages"; +import { AUTH_PAGES, PHOTOS_PAGES } from "@ente/shared/constants/pages"; /** - * The "home" route for each of our apps. + * The default page ("home route") for each of our apps. * * This is where we redirect to after successful authentication. */ export const appHomeRoute = (appName: AppName): string => { switch (appName) { - case "account": - return ACCOUNTS_PAGES.PASSKEYS; - case "albums": - return "/"; + case "accounts": + return "/passkeys"; case "auth": return AUTH_PAGES.AUTH; case "photos": diff --git a/web/packages/accounts/tsconfig.json b/web/packages/accounts/tsconfig.json index bf522b090f..d660fffb3b 100644 --- a/web/packages/accounts/tsconfig.json +++ b/web/packages/accounts/tsconfig.json @@ -1,21 +1,14 @@ { - "extends": "../../tsconfig.base.json", + "extends": "@/build-config/tsconfig-next.json", "compilerOptions": { - "downlevelIteration": true, - "jsx": "preserve", - "jsxImportSource": "@emotion/react", - "lib": ["dom", "dom.iterable", "esnext", "webworker"], - "noImplicitAny": false, - "noUnusedLocals": false, - "noUnusedParameters": false, - "strictNullChecks": false, - "target": "es5", - "useUnknownInCatchVariables": false + /* TODO(MR): Enable this */ + "noUncheckedIndexedAccess": false, + /* MUI doesn't play great with exactOptionalPropertyTypes currently. */ + "exactOptionalPropertyTypes": false }, "include": [ - "**/*.ts", - "**/*.tsx", - "**/*.js", + ".", + "../next/global-electron.d.ts", "../shared/themes/mui-theme.d.ts" ] } diff --git a/web/packages/build-config/eslintrc-base.js b/web/packages/build-config/eslintrc-base.js index 3e65638c1b..95d9976521 100644 --- a/web/packages/build-config/eslintrc-base.js +++ b/web/packages/build-config/eslintrc-base.js @@ -25,5 +25,20 @@ module.exports = { ignoreArrowShorthand: true, }, ], + /* + Allow async functions to be passed as JSX attributes expected to be + functions that return void (typically onFoo event handlers). + + This should be safe since we have registered global unhandled Promise + handlers. + */ + "@typescript-eslint/no-misused-promises": [ + "error", + { + checksVoidReturn: { + attributes: false, + }, + }, + ], }, }; diff --git a/web/packages/build-config/tsconfig-vite.json b/web/packages/build-config/tsconfig-vite.json index 8a0d12f15f..e4ff26ca2d 100644 --- a/web/packages/build-config/tsconfig-vite.json +++ b/web/packages/build-config/tsconfig-vite.json @@ -1,5 +1,5 @@ { - /* TSConfig file used for typechecking the files in src/ + /* TSConfig file used for typechecking the files in src/. * * The base configuration was generated using `yarn create vite`. This was * already almost the same as the `tsconfig-typecheck.json` we use @@ -15,7 +15,7 @@ "module": "esnext", "skipLibCheck": true, - /* Bundler mode */ + /* Bundler mode. */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "resolveJsonModule": true, @@ -23,7 +23,7 @@ "noEmit": true, "jsx": "react-jsx", - /* Linting */ + /* Linting. */ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, @@ -34,12 +34,12 @@ * strictness checks. */ - /* Require the `type` modifier when importing types */ + /* Require the `type` modifier when importing types. */ "verbatimModuleSyntax": true, - /* Stricter than strict */ + /* Stricter than strict. */ "noImplicitReturns": true, - /* e.g. makes array indexing returns undefined */ + /* e.g. makes array indexing returns undefined. */ "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true }, diff --git a/web/packages/build-config/tsconfig-vite.node.json b/web/packages/build-config/tsconfig-vite.node.json index d7639ac8e6..71c4923013 100644 --- a/web/packages/build-config/tsconfig-vite.node.json +++ b/web/packages/build-config/tsconfig-vite.node.json @@ -1,5 +1,5 @@ { - /* TSConfig file used for typechecking vite's config file itself + /* TSConfig file used for typechecking vite's config file itself. * * These are vite defaults, generated using `yarn create vite`. */ diff --git a/web/packages/new/photos/components/WhatsNew.tsx b/web/packages/new/photos/components/WhatsNew.tsx new file mode 100644 index 0000000000..9a98ee5e48 --- /dev/null +++ b/web/packages/new/photos/components/WhatsNew.tsx @@ -0,0 +1,118 @@ +import ArrowForward from "@mui/icons-material/ArrowForward"; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Typography, + styled, + useMediaQuery, +} from "@mui/material"; +import Slide from "@mui/material/Slide"; +import type { TransitionProps } from "@mui/material/transitions"; +import React, { useEffect } from "react"; +import { didShowWhatsNew } from "../services/changelog"; + +interface WhatsNewProps { + /** If `true`, then the dialog is shown. */ + open: boolean; + /** Callback to invoke when the dialog wants to be closed. */ + onClose: () => void; +} + +/** + * Show a dialog showing a short summary of interesting-for-the-user things + * since the last time this dialog was shown. + */ +export const WhatsNew: React.FC = ({ open, onClose }) => { + const fullScreen = useMediaQuery("(max-width: 428px)"); + + useEffect(() => { + if (open) void didShowWhatsNew(); + }, [open]); + + return ( + + {"What's new"} + + + + + + + } + > + {"Continue"} + + + + ); +}; + +const SlideTransition = React.forwardRef(function Transition( + props: TransitionProps & { + children: React.ReactElement; + }, + ref: React.Ref, +) { + return ; +}); + +const ChangelogContent: React.FC = () => { + // NOTE: Remember to update changelogVersion when changing the content + // below. + + return ( + +
  • + + + Support for Passkeys + + Passkeys can now be used as a second factor authentication + mechanism. + +
  • +
  • + Window size + + {"The app's window will remember its size and position."} + +
  • +
    + ); +}; + +const StyledUL = styled("ul")` + padding-inline: 1rem; + + li { + margin-block: 2rem; + } +`; + +const StyledButton = styled(Button)` + /* Show an outline when the button gains keyboard focus, e.g. when the user + tabs to it. */ + &.Mui-focusVisible { + outline: 1px solid #aaa; + } +`; + +const ButtonContents = styled("div")` + /* Make the button text fill the entire space so the endIcon shows at the + trailing edge of the button. */ + width: 100%; + text-align: left; +`; diff --git a/web/packages/new/photos/services/changelog.ts b/web/packages/new/photos/services/changelog.ts new file mode 100644 index 0000000000..9fa1ed942a --- /dev/null +++ b/web/packages/new/photos/services/changelog.ts @@ -0,0 +1,39 @@ +import { ensureElectron } from "@/next/electron"; + +/** + * The current changelog version. + * + * [Note: Conditions for showing "What's new"] + * + * We maintain a "changelog version". This version is an incrementing positive + * integer, we increment it whenever we want to show this dialog again. Usually + * we'd do this for each app update, but not necessarily. + * + * The "What's new" dialog is shown when either we do not have a previously + * saved changelog version, or if the saved changelog version is less than the + * current {@link changelogVersion}. + * + * The shown changelog version is persisted on the Node.js layer since there we + * can store it in the user preferences store, which is not cleared on logout. + * + * On app start, the Node.js layer waits for the {@link onShowWhatsNew} callback + * to get attached. When a callback is attached, it checks the above conditions + * and if they are satisfied, it invokes the callback. The callback should + * return the current {@link changelogVersion} to allow the Node.js layer to + * update the persisted state. + */ +const changelogVersion = 1; + +/** + * Return true if we should show the {@link WhatsNew} dialog. + */ +export const shouldShowWhatsNew = async () => { + const electron = globalThis.electron; + if (!electron) return false; + const lastShownVersion = (await electron.lastShownChangelogVersion()) ?? 0; + return lastShownVersion < changelogVersion; +}; + +export const didShowWhatsNew = async () => + // We should only have been called if we're in electron. + ensureElectron().setLastShownChangelogVersion(changelogVersion); diff --git a/web/packages/new/photos/services/feature-flags.ts b/web/packages/new/photos/services/feature-flags.ts index 419c6baf26..ab7787b75e 100644 --- a/web/packages/new/photos/services/feature-flags.ts +++ b/web/packages/new/photos/services/feature-flags.ts @@ -1,10 +1,9 @@ import { isDevBuild } from "@/next/env"; +import { authenticatedRequestHeaders } from "@/next/http"; import { localUser } from "@/next/local-user"; import log from "@/next/log"; -import { ensure } from "@/utils/ensure"; import { nullToUndefined } from "@/utils/transform"; import { apiOrigin } from "@ente/shared/network/api"; -import { getToken } from "@ente/shared/storage/localStorage/helpers"; import { z } from "zod"; let _fetchTimeout: ReturnType | undefined; @@ -68,9 +67,7 @@ const fetchAndSaveFeatureFlags = () => const fetchFeatureFlags = async () => { const url = `${apiOrigin()}/remote-store/feature-flags`; const res = await fetch(url, { - headers: { - "X-Auth-Token": ensure(getToken()), - }, + headers: authenticatedRequestHeaders(), }); if (!res.ok) throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`); return res; diff --git a/web/packages/new/tsconfig.json b/web/packages/new/tsconfig.json index d30f8c21f3..954e092ba3 100644 --- a/web/packages/new/tsconfig.json +++ b/web/packages/new/tsconfig.json @@ -1,4 +1,12 @@ { "extends": "@/build-config/tsconfig-typecheck.json", - "include": [".", "../next/global-electron.d.ts"] + "compilerOptions": { + /* MUI doesn't play great with exactOptionalPropertyTypes currently. */ + "exactOptionalPropertyTypes": false + }, + "include": [ + ".", + "../next/global-electron.d.ts", + "../shared/themes/mui-theme.d.ts" + ] } diff --git a/web/packages/next/components/Card.tsx b/web/packages/next/components/Card.tsx index ee19e9f52f..c6602404c0 100644 --- a/web/packages/next/components/Card.tsx +++ b/web/packages/next/components/Card.tsx @@ -1,4 +1,4 @@ -import * as React from "react"; +import React from "react"; export const Card: React.FC = () => { return
    Hello
    ; diff --git a/web/packages/next/http.ts b/web/packages/next/http.ts new file mode 100644 index 0000000000..bf64a9b3a8 --- /dev/null +++ b/web/packages/next/http.ts @@ -0,0 +1,60 @@ +import { ensureAuthToken } from "./local-user"; +import { clientPackageName, type AppName } from "./types/app"; + +/** + * Value for the the "X-Client-Package" header in authenticated requests. + */ +let _clientPackage: string | undefined; + +/** + * Remember that we should include the client package corresponding to the given + * {@link appName} as the "X-Client-Package" header in authenticated requests. + * + * This state is persisted in memory, and can be cleared using + * {@link clearHTTPState}. + * + * @param appName The {@link AppName} of the current app. + */ +export const setAppNameForAuthenticatedRequests = (appName: AppName) => { + _clientPackage = clientPackageName(appName); +}; + +/** + * Variant of {@link setAppNameForAuthenticatedRequests} that sets directly sets + * the client package to the provided string. + */ +export const setClientPackageForAuthenticatedRequests = (p: string) => { + _clientPackage = p; +}; + +/** + * Forget the effects of a previous {@link setAppNameForAuthenticatedRequests} + * or {@link setClientPackageForAuthenticatedRequests}. + */ +export const clearHTTPState = () => { + _clientPackage = undefined; +}; + +/** + * Return headers that should be passed alongwith (almost) all authenticated + * `fetch` calls that we make to our API servers. + * + * This uses in-memory state (See {@link clearHTTPState}). + */ +export const authenticatedRequestHeaders = (): Record => { + const headers: Record = { + "X-Auth-Token": ensureAuthToken(), + }; + if (_clientPackage) headers["X-Client-Package"] = _clientPackage; + return headers; +}; + +/** + * Return a headers object with "X-Client-Package" header if we have the client + * package value available to us from local storage. + */ +export const clientPackageHeaderIfPresent = (): Record => { + const headers: Record = {}; + if (_clientPackage) headers["X-Client-Package"] = _clientPackage; + return headers; +}; diff --git a/web/packages/next/local-user.ts b/web/packages/next/local-user.ts index 2a351a421b..d657264287 100644 --- a/web/packages/next/local-user.ts +++ b/web/packages/next/local-user.ts @@ -38,3 +38,14 @@ export const ensureLocalUser = (): LocalUser => { if (!user) throw new Error("Not logged in"); return user; }; + +/** + * Return the user's auth token, or throw an error. + * + * The user's auth token is stored in local storage after they have successfully + * logged in. This function returns that saved auth token. + * + * If no such token is found (which should only happen if the user is not logged + * in), then it throws an error. + */ +export const ensureAuthToken = (): string => ensureLocalUser().token; diff --git a/web/packages/next/locales/ar-SA/translation.json b/web/packages/next/locales/ar-SA/translation.json new file mode 100644 index 0000000000..8c268c2c86 --- /dev/null +++ b/web/packages/next/locales/ar-SA/translation.json @@ -0,0 +1,643 @@ +{ + "HERO_SLIDE_1_TITLE": "", + "HERO_SLIDE_1": "", + "HERO_SLIDE_2_TITLE": "", + "HERO_SLIDE_2": "", + "HERO_SLIDE_3_TITLE": "", + "HERO_SLIDE_3": "", + "LOGIN": "", + "SIGN_UP": "", + "NEW_USER": "", + "EXISTING_USER": "", + "ENTER_NAME": "", + "PUBLIC_UPLOADER_NAME_MESSAGE": "", + "ENTER_EMAIL": "", + "EMAIL_ERROR": "", + "REQUIRED": "", + "EMAIL_SENT": "", + "CHECK_INBOX": "", + "ENTER_OTT": "", + "RESEND_MAIL": "", + "VERIFY": "", + "UNKNOWN_ERROR": "", + "INVALID_CODE": "", + "EXPIRED_CODE": "", + "SENDING": "", + "SENT": "", + "password": "", + "link_password_description": "", + "unlock": "", + "SET_PASSPHRASE": "", + "VERIFY_PASSPHRASE": "", + "INCORRECT_PASSPHRASE": "", + "ENTER_ENC_PASSPHRASE": "", + "PASSPHRASE_DISCLAIMER": "", + "WELCOME_TO_ENTE_HEADING": "", + "WELCOME_TO_ENTE_SUBHEADING": "", + "WHERE_YOUR_BEST_PHOTOS_LIVE": "", + "KEY_GENERATION_IN_PROGRESS_MESSAGE": "", + "PASSPHRASE_HINT": "", + "CONFIRM_PASSPHRASE": "", + "REFERRAL_CODE_HINT": "", + "REFERRAL_INFO": "", + "PASSPHRASE_MATCH_ERROR": "", + "CREATE_COLLECTION": "", + "ENTER_ALBUM_NAME": "", + "CLOSE_OPTION": "", + "ENTER_FILE_NAME": "", + "CLOSE": "", + "NO": "", + "NOTHING_HERE": "", + "UPLOAD": "", + "IMPORT": "", + "ADD_PHOTOS": "", + "ADD_MORE_PHOTOS": "", + "add_photos_one": "", + "add_photos_other": "", + "SELECT_PHOTOS": "", + "FILE_UPLOAD": "", + "UPLOAD_STAGE_MESSAGE": { + "0": "", + "1": "", + "2": "", + "3": "", + "4": "", + "5": "" + }, + "FILE_NOT_UPLOADED_LIST": "", + "SUBSCRIPTION_EXPIRED": "", + "SUBSCRIPTION_EXPIRED_MESSAGE": "", + "STORAGE_QUOTA_EXCEEDED": "", + "INITIAL_LOAD_DELAY_WARNING": "", + "USER_DOES_NOT_EXIST": "", + "NO_ACCOUNT": "", + "ACCOUNT_EXISTS": "", + "CREATE": "", + "DOWNLOAD": "", + "DOWNLOAD_OPTION": "", + "DOWNLOAD_FAVORITES": "", + "DOWNLOAD_UNCATEGORIZED": "", + "DOWNLOAD_HIDDEN_ITEMS": "", + "COPY_OPTION": "", + "TOGGLE_FULLSCREEN": "", + "ZOOM_IN_OUT": "", + "PREVIOUS": "", + "NEXT": "", + "title_photos": "", + "title_auth": "", + "title_accounts": "", + "UPLOAD_FIRST_PHOTO": "", + "IMPORT_YOUR_FOLDERS": "", + "UPLOAD_DROPZONE_MESSAGE": "", + "WATCH_FOLDER_DROPZONE_MESSAGE": "", + "TRASH_FILES_TITLE": "", + "TRASH_FILE_TITLE": "", + "DELETE_FILES_TITLE": "", + "DELETE_FILES_MESSAGE": "", + "DELETE": "", + "DELETE_OPTION": "", + "FAVORITE_OPTION": "", + "UNFAVORITE_OPTION": "", + "MULTI_FOLDER_UPLOAD": "", + "UPLOAD_STRATEGY_CHOICE": "", + "UPLOAD_STRATEGY_SINGLE_COLLECTION": "", + "OR": "", + "UPLOAD_STRATEGY_COLLECTION_PER_FOLDER": "", + "SESSION_EXPIRED_MESSAGE": "", + "SESSION_EXPIRED": "", + "PASSWORD_GENERATION_FAILED": "", + "CHANGE_PASSWORD": "", + "GO_BACK": "", + "RECOVERY_KEY": "", + "SAVE_LATER": "", + "SAVE": "", + "RECOVERY_KEY_DESCRIPTION": "", + "RECOVER_KEY_GENERATION_FAILED": "", + "KEY_NOT_STORED_DISCLAIMER": "", + "FORGOT_PASSWORD": "", + "RECOVER_ACCOUNT": "", + "RECOVERY_KEY_HINT": "", + "RECOVER": "", + "NO_RECOVERY_KEY": "", + "INCORRECT_RECOVERY_KEY": "", + "SORRY": "", + "NO_RECOVERY_KEY_MESSAGE": "", + "NO_TWO_FACTOR_RECOVERY_KEY_MESSAGE": "", + "CONTACT_SUPPORT": "", + "REQUEST_FEATURE": "", + "SUPPORT": "", + "CONFIRM": "", + "CANCEL": "", + "LOGOUT": "", + "DELETE_ACCOUNT": "", + "DELETE_ACCOUNT_MESSAGE": "", + "LOGOUT_MESSAGE": "", + "CHANGE_EMAIL": "", + "OK": "", + "SUCCESS": "", + "ERROR": "", + "MESSAGE": "", + "INSTALL_MOBILE_APP": "", + "DOWNLOAD_APP_MESSAGE": "", + "DOWNLOAD_APP": "", + "EXPORT": "", + "SUBSCRIPTION": "", + "SUBSCRIBE": "", + "MANAGEMENT_PORTAL": "", + "MANAGE_FAMILY_PORTAL": "", + "LEAVE_FAMILY_PLAN": "", + "LEAVE": "", + "LEAVE_FAMILY_CONFIRM": "", + "CHOOSE_PLAN": "", + "MANAGE_PLAN": "", + "ACTIVE": "", + "OFFLINE_MSG": "", + "FREE_SUBSCRIPTION_INFO": "", + "FAMILY_SUBSCRIPTION_INFO": "", + "RENEWAL_ACTIVE_SUBSCRIPTION_STATUS": "", + "RENEWAL_CANCELLED_SUBSCRIPTION_STATUS": "", + "RENEWAL_CANCELLED_SUBSCRIPTION_INFO": "", + "ADD_ON_AVAILABLE_TILL": "", + "STORAGE_QUOTA_EXCEEDED_SUBSCRIPTION_INFO": "", + "SUBSCRIPTION_PURCHASE_SUCCESS": "", + "SUBSCRIPTION_PURCHASE_CANCELLED": "", + "SUBSCRIPTION_PURCHASE_FAILED": "", + "SUBSCRIPTION_UPDATE_FAILED": "", + "UPDATE_PAYMENT_METHOD_MESSAGE": "", + "STRIPE_AUTHENTICATION_FAILED": "", + "UPDATE_PAYMENT_METHOD": "", + "MONTHLY": "", + "YEARLY": "", + "update_subscription_title": "", + "UPDATE_SUBSCRIPTION_MESSAGE": "", + "UPDATE_SUBSCRIPTION": "", + "CANCEL_SUBSCRIPTION": "", + "CANCEL_SUBSCRIPTION_MESSAGE": "", + "CANCEL_SUBSCRIPTION_WITH_ADDON_MESSAGE": "", + "SUBSCRIPTION_CANCEL_FAILED": "", + "SUBSCRIPTION_CANCEL_SUCCESS": "", + "REACTIVATE_SUBSCRIPTION": "", + "REACTIVATE_SUBSCRIPTION_MESSAGE": "", + "SUBSCRIPTION_ACTIVATE_SUCCESS": "", + "SUBSCRIPTION_ACTIVATE_FAILED": "", + "SUBSCRIPTION_PURCHASE_SUCCESS_TITLE": "", + "CANCEL_SUBSCRIPTION_ON_MOBILE": "", + "CANCEL_SUBSCRIPTION_ON_MOBILE_MESSAGE": "", + "MAIL_TO_MANAGE_SUBSCRIPTION": "", + "RENAME": "", + "RENAME_FILE": "", + "RENAME_COLLECTION": "", + "DELETE_COLLECTION_TITLE": "", + "DELETE_COLLECTION": "", + "DELETE_COLLECTION_MESSAGE": "", + "DELETE_PHOTOS": "", + "KEEP_PHOTOS": "", + "SHARE_COLLECTION": "", + "SHARE_WITH_SELF": "", + "ALREADY_SHARED": "", + "SHARING_BAD_REQUEST_ERROR": "", + "SHARING_DISABLED_FOR_FREE_ACCOUNTS": "", + "DOWNLOAD_COLLECTION": "", + "CREATE_ALBUM_FAILED": "", + "SEARCH": "", + "SEARCH_RESULTS": "", + "NO_RESULTS": "", + "SEARCH_HINT": "", + "SEARCH_TYPE": { + "COLLECTION": "", + "LOCATION": "", + "CITY": "", + "DATE": "", + "FILE_NAME": "", + "THING": "", + "FILE_CAPTION": "", + "FILE_TYPE": "", + "CLIP": "" + }, + "photos_count_zero": "", + "photos_count_one": "", + "photos_count_other": "", + "TERMS_AND_CONDITIONS": "", + "ADD_TO_COLLECTION": "", + "SELECTED": "", + "PEOPLE": "", + "INDEXING_SCHEDULED": "", + "ANALYZING_PHOTOS": "", + "INDEXING_PEOPLE": "", + "INDEXING_DONE": "", + "UNIDENTIFIED_FACES": "", + "OBJECTS": "", + "TEXT": "", + "INFO": "", + "INFO_OPTION": "", + "FILE_NAME": "", + "CAPTION_PLACEHOLDER": "", + "LOCATION": "", + "SHOW_ON_MAP": "", + "MAP": "", + "MAP_SETTINGS": "", + "ENABLE_MAPS": "", + "ENABLE_MAP": "", + "DISABLE_MAPS": "", + "ENABLE_MAP_DESCRIPTION": "", + "DISABLE_MAP_DESCRIPTION": "", + "DISABLE_MAP": "", + "DETAILS": "", + "VIEW_EXIF": "", + "NO_EXIF": "", + "EXIF": "", + "ISO": "", + "TWO_FACTOR": "", + "TWO_FACTOR_AUTHENTICATION": "", + "TWO_FACTOR_QR_INSTRUCTION": "", + "ENTER_CODE_MANUALLY": "", + "TWO_FACTOR_MANUAL_CODE_INSTRUCTION": "", + "SCAN_QR_CODE": "", + "ENABLE_TWO_FACTOR": "", + "ENABLE": "", + "LOST_DEVICE": "", + "INCORRECT_CODE": "", + "TWO_FACTOR_INFO": "", + "DISABLE_TWO_FACTOR_LABEL": "", + "UPDATE_TWO_FACTOR_LABEL": "", + "DISABLE": "", + "RECONFIGURE": "", + "UPDATE_TWO_FACTOR": "", + "UPDATE_TWO_FACTOR_MESSAGE": "", + "UPDATE": "", + "DISABLE_TWO_FACTOR": "", + "DISABLE_TWO_FACTOR_MESSAGE": "", + "TWO_FACTOR_DISABLE_FAILED": "", + "EXPORT_DATA": "", + "SELECT_FOLDER": "", + "DESTINATION": "", + "START": "", + "LAST_EXPORT_TIME": "", + "EXPORT_AGAIN": "", + "LOCAL_STORAGE_NOT_ACCESSIBLE": "", + "LOCAL_STORAGE_NOT_ACCESSIBLE_MESSAGE": "", + "SEND_OTT": "", + "EMAIl_ALREADY_OWNED": "", + "ETAGS_BLOCKED": "", + "LIVE_PHOTOS_DETECTED": "", + "RETRY_FAILED": "", + "FAILED_UPLOADS": "", + "SKIPPED_FILES": "", + "THUMBNAIL_GENERATION_FAILED_UPLOADS": "", + "UNSUPPORTED_FILES": "", + "SUCCESSFUL_UPLOADS": "", + "SKIPPED_INFO": "", + "UNSUPPORTED_INFO": "", + "BLOCKED_UPLOADS": "", + "INPROGRESS_METADATA_EXTRACTION": "", + "INPROGRESS_UPLOADS": "", + "TOO_LARGE_UPLOADS": "", + "LARGER_THAN_AVAILABLE_STORAGE_UPLOADS": "", + "LARGER_THAN_AVAILABLE_STORAGE_INFO": "", + "TOO_LARGE_INFO": "", + "THUMBNAIL_GENERATION_FAILED_INFO": "", + "UPLOAD_TO_COLLECTION": "", + "UNCATEGORIZED": "", + "ARCHIVE": "", + "FAVORITES": "", + "ARCHIVE_COLLECTION": "", + "ARCHIVE_SECTION_NAME": "", + "ALL_SECTION_NAME": "", + "MOVE_TO_COLLECTION": "", + "UNARCHIVE": "", + "UNARCHIVE_COLLECTION": "", + "HIDE_COLLECTION": "", + "UNHIDE_COLLECTION": "", + "MOVE": "", + "ADD": "", + "REMOVE": "", + "YES_REMOVE": "", + "REMOVE_FROM_COLLECTION": "", + "TRASH": "", + "MOVE_TO_TRASH": "", + "TRASH_FILES_MESSAGE": "", + "TRASH_FILE_MESSAGE": "", + "DELETE_PERMANENTLY": "", + "RESTORE": "", + "RESTORE_TO_COLLECTION": "", + "EMPTY_TRASH": "", + "EMPTY_TRASH_TITLE": "", + "EMPTY_TRASH_MESSAGE": "", + "LEAVE_SHARED_ALBUM": "", + "LEAVE_ALBUM": "", + "LEAVE_SHARED_ALBUM_TITLE": "", + "LEAVE_SHARED_ALBUM_MESSAGE": "", + "NOT_FILE_OWNER": "", + "CONFIRM_SELF_REMOVE_MESSAGE": "", + "CONFIRM_SELF_AND_OTHER_REMOVE_MESSAGE": "", + "SORT_BY_CREATION_TIME_ASCENDING": "", + "SORT_BY_UPDATION_TIME_DESCENDING": "", + "SORT_BY_NAME": "", + "FIX_CREATION_TIME": "", + "FIX_CREATION_TIME_IN_PROGRESS": "", + "CREATION_TIME_UPDATED": "", + "UPDATE_CREATION_TIME_NOT_STARTED": "", + "UPDATE_CREATION_TIME_COMPLETED": "", + "UPDATE_CREATION_TIME_COMPLETED_WITH_ERROR": "", + "CAPTION_CHARACTER_LIMIT": "", + "DATE_TIME_ORIGINAL": "", + "DATE_TIME_DIGITIZED": "", + "METADATA_DATE": "", + "CUSTOM_TIME": "", + "REOPEN_PLAN_SELECTOR_MODAL": "", + "OPEN_PLAN_SELECTOR_MODAL_FAILED": "", + "INSTALL": "", + "SHARING_DETAILS": "", + "MODIFY_SHARING": "", + "ADD_COLLABORATORS": "", + "ADD_NEW_EMAIL": "", + "shared_with_people_zero": "", + "shared_with_people_one": "", + "shared_with_people_other": "", + "participants_zero": "", + "participants_one": "", + "participants_other": "", + "ADD_VIEWERS": "", + "CHANGE_PERMISSIONS_TO_VIEWER": "", + "CHANGE_PERMISSIONS_TO_COLLABORATOR": "", + "CONVERT_TO_VIEWER": "", + "CONVERT_TO_COLLABORATOR": "", + "CHANGE_PERMISSION": "", + "REMOVE_PARTICIPANT": "", + "CONFIRM_REMOVE": "", + "MANAGE": "", + "ADDED_AS": "", + "COLLABORATOR_RIGHTS": "", + "REMOVE_PARTICIPANT_HEAD": "", + "OWNER": "", + "COLLABORATORS": "", + "ADD_MORE": "", + "VIEWERS": "", + "OR_ADD_EXISTING": "", + "REMOVE_PARTICIPANT_MESSAGE": "", + "NOT_FOUND": "", + "LINK_EXPIRED": "", + "LINK_EXPIRED_MESSAGE": "", + "MANAGE_LINK": "", + "LINK_TOO_MANY_REQUESTS": "", + "FILE_DOWNLOAD": "", + "link_password_lock": "", + "PUBLIC_COLLECT": "", + "LINK_DEVICE_LIMIT": "", + "NO_DEVICE_LIMIT": "", + "LINK_EXPIRY": "", + "NEVER": "", + "DISABLE_FILE_DOWNLOAD": "", + "DISABLE_FILE_DOWNLOAD_MESSAGE": "", + "SHARED_USING": "", + "SHARING_REFERRAL_CODE": "", + "LIVE": "", + "DISABLE_PASSWORD": "", + "DISABLE_PASSWORD_MESSAGE": "", + "PASSWORD_LOCK": "", + "LOCK": "", + "DOWNLOAD_UPLOAD_LOGS": "", + "UPLOAD_FILES": "", + "UPLOAD_DIRS": "", + "UPLOAD_GOOGLE_TAKEOUT": "", + "DEDUPLICATE_FILES": "", + "NO_DUPLICATES_FOUND": "", + "FILES": "", + "EACH": "", + "DEDUPLICATE_BASED_ON_SIZE": "", + "STOP_ALL_UPLOADS_MESSAGE": "", + "STOP_UPLOADS_HEADER": "", + "YES_STOP_UPLOADS": "", + "STOP_DOWNLOADS_HEADER": "", + "YES_STOP_DOWNLOADS": "", + "STOP_ALL_DOWNLOADS_MESSAGE": "", + "albums_one": "", + "albums_other": "", + "ALL_ALBUMS": "", + "ALBUMS": "", + "ALL_HIDDEN_ALBUMS": "", + "HIDDEN_ALBUMS": "", + "HIDDEN_ITEMS": "", + "ENTER_TWO_FACTOR_OTP": "", + "CREATE_ACCOUNT": "", + "COPIED": "", + "WATCH_FOLDERS": "", + "UPGRADE_NOW": "", + "RENEW_NOW": "", + "STORAGE": "", + "USED": "", + "YOU": "", + "FAMILY": "", + "FREE": "", + "OF": "", + "WATCHED_FOLDERS": "", + "NO_FOLDERS_ADDED": "", + "FOLDERS_AUTOMATICALLY_MONITORED": "", + "UPLOAD_NEW_FILES_TO_ENTE": "", + "REMOVE_DELETED_FILES_FROM_ENTE": "", + "ADD_FOLDER": "", + "STOP_WATCHING": "", + "STOP_WATCHING_FOLDER": "", + "STOP_WATCHING_DIALOG_MESSAGE": "", + "YES_STOP": "", + "MONTH_SHORT": "", + "YEAR": "", + "FAMILY_PLAN": "", + "DOWNLOAD_LOGS": "", + "DOWNLOAD_LOGS_MESSAGE": "", + "CHANGE_FOLDER": "", + "TWO_MONTHS_FREE": "", + "POPULAR": "", + "FREE_PLAN_OPTION_LABEL": "", + "free_plan_description": "", + "CURRENT_USAGE": "", + "WEAK_DEVICE": "", + "DRAG_AND_DROP_HINT": "", + "CONFIRM_ACCOUNT_DELETION_MESSAGE": "", + "AUTHENTICATE": "", + "UPLOADED_TO_SINGLE_COLLECTION": "", + "UPLOADED_TO_SEPARATE_COLLECTIONS": "", + "NEVERMIND": "", + "UPDATE_AVAILABLE": "", + "UPDATE_INSTALLABLE_MESSAGE": "", + "INSTALL_NOW": "", + "INSTALL_ON_NEXT_LAUNCH": "", + "UPDATE_AVAILABLE_MESSAGE": "", + "DOWNLOAD_AND_INSTALL": "", + "IGNORE_THIS_VERSION": "", + "TODAY": "", + "YESTERDAY": "", + "NAME_PLACEHOLDER": "", + "ROOT_LEVEL_FILE_WITH_FOLDER_NOT_ALLOWED": "", + "ROOT_LEVEL_FILE_WITH_FOLDER_NOT_ALLOWED_MESSAGE": "", + "CHOSE_THEME": "", + "ML_SEARCH": "", + "ENABLE_ML_SEARCH_DESCRIPTION": "", + "ML_MORE_DETAILS": "", + "ENABLE_FACE_SEARCH": "", + "ENABLE_FACE_SEARCH_TITLE": "", + "ENABLE_FACE_SEARCH_DESCRIPTION": "", + "DISABLE_BETA": "", + "DISABLE_FACE_SEARCH": "", + "DISABLE_FACE_SEARCH_TITLE": "", + "DISABLE_FACE_SEARCH_DESCRIPTION": "", + "ADVANCED": "", + "FACE_SEARCH_CONFIRMATION": "", + "LABS": "", + "YOURS": "", + "PASSPHRASE_STRENGTH_WEAK": "", + "PASSPHRASE_STRENGTH_MODERATE": "", + "PASSPHRASE_STRENGTH_STRONG": "", + "PREFERENCES": "", + "LANGUAGE": "", + "EXPORT_DIRECTORY_DOES_NOT_EXIST": "", + "EXPORT_DIRECTORY_DOES_NOT_EXIST_MESSAGE": "", + "SUBSCRIPTION_VERIFICATION_ERROR": "", + "storage_unit": { + "b": "", + "kb": "", + "mb": "", + "gb": "", + "tb": "" + }, + "AFTER_TIME": { + "HOUR": "", + "DAY": "", + "WEEK": "", + "MONTH": "", + "YEAR": "" + }, + "COPY_LINK": "", + "DONE": "", + "LINK_SHARE_TITLE": "", + "REMOVE_LINK": "", + "CREATE_PUBLIC_SHARING": "", + "PUBLIC_LINK_CREATED": "", + "PUBLIC_LINK_ENABLED": "", + "COLLECT_PHOTOS": "", + "PUBLIC_COLLECT_SUBTEXT": "", + "STOP_EXPORT": "", + "EXPORT_PROGRESS": "", + "MIGRATING_EXPORT": "", + "RENAMING_COLLECTION_FOLDERS": "", + "TRASHING_DELETED_FILES": "", + "TRASHING_DELETED_COLLECTIONS": "", + "CONTINUOUS_EXPORT": "", + "PENDING_ITEMS": "", + "EXPORT_STARTING": "", + "DELETE_ACCOUNT_REASON_LABEL": "", + "DELETE_ACCOUNT_REASON_PLACEHOLDER": "", + "DELETE_REASON": { + "MISSING_FEATURE": "", + "BROKEN_BEHAVIOR": "", + "FOUND_ANOTHER_SERVICE": "", + "NOT_LISTED": "" + }, + "DELETE_ACCOUNT_FEEDBACK_LABEL": "", + "DELETE_ACCOUNT_FEEDBACK_PLACEHOLDER": "", + "CONFIRM_DELETE_ACCOUNT_CHECKBOX_LABEL": "", + "CONFIRM_DELETE_ACCOUNT": "", + "FEEDBACK_REQUIRED": "", + "FEEDBACK_REQUIRED_FOUND_ANOTHER_SERVICE": "", + "RECOVER_TWO_FACTOR": "", + "at": "", + "AUTH_NEXT": "", + "AUTH_DOWNLOAD_MOBILE_APP": "", + "HIDDEN": "", + "HIDE": "", + "UNHIDE": "", + "UNHIDE_TO_COLLECTION": "", + "SORT_BY": "", + "NEWEST_FIRST": "", + "OLDEST_FIRST": "", + "CONVERSION_FAILED_NOTIFICATION_MESSAGE": "", + "SELECT_COLLECTION": "", + "PIN_ALBUM": "", + "UNPIN_ALBUM": "", + "DOWNLOAD_COMPLETE": "", + "DOWNLOADING_COLLECTION": "", + "DOWNLOAD_FAILED": "", + "DOWNLOAD_PROGRESS": "", + "CHRISTMAS": "", + "CHRISTMAS_EVE": "", + "NEW_YEAR": "", + "NEW_YEAR_EVE": "", + "IMAGE": "", + "VIDEO": "", + "LIVE_PHOTO": "", + "editor": { + "crop": "" + }, + "CONVERT": "", + "CONFIRM_EDITOR_CLOSE_MESSAGE": "", + "CONFIRM_EDITOR_CLOSE_DESCRIPTION": "", + "BRIGHTNESS": "", + "CONTRAST": "", + "SATURATION": "", + "BLUR": "", + "INVERT_COLORS": "", + "ASPECT_RATIO": "", + "SQUARE": "", + "ROTATE_LEFT": "", + "ROTATE_RIGHT": "", + "FLIP_VERTICALLY": "", + "FLIP_HORIZONTALLY": "", + "DOWNLOAD_EDITED": "", + "SAVE_A_COPY_TO_ENTE": "", + "RESTORE_ORIGINAL": "", + "TRANSFORM": "", + "COLORS": "", + "FLIP": "", + "ROTATION": "", + "RESET": "", + "PHOTO_EDITOR": "", + "FASTER_UPLOAD": "", + "FASTER_UPLOAD_DESCRIPTION": "", + "MAGIC_SEARCH_STATUS": "", + "INDEXED_ITEMS": "", + "CAST_ALBUM_TO_TV": "", + "ENTER_CAST_PIN_CODE": "", + "PAIR_DEVICE_TO_TV": "", + "TV_NOT_FOUND": "", + "AUTO_CAST_PAIR": "", + "AUTO_CAST_PAIR_DESC": "", + "PAIR_WITH_PIN": "", + "CHOOSE_DEVICE_FROM_BROWSER": "", + "PAIR_WITH_PIN_DESC": "", + "VISIT_CAST_ENTE_IO": "", + "CAST_AUTO_PAIR_FAILED": "", + "FREEHAND": "", + "APPLY_CROP": "", + "PHOTO_EDIT_REQUIRED_TO_SAVE": "", + "passkeys": "", + "passkey_fetch_failed": "", + "manage_passkey": "", + "delete_passkey": "", + "delete_passkey_confirmation": "", + "rename_passkey": "", + "add_passkey": "", + "enter_passkey_name": "", + "passkeys_description": "", + "CREATED_AT": "", + "passkey_add_failed": "", + "passkey_login_failed": "", + "passkey_login_invalid_url": "", + "passkey_login_already_claimed_session": "", + "passkey_login_generic_error": "", + "passkey_login_credential_hint": "", + "passkeys_not_supported": "", + "try_again": "", + "check_status": "", + "passkey_login_instructions": "", + "passkey_login": "", + "passkey": "", + "passkey_verify_description": "", + "waiting_for_verification": "", + "verification_still_pending": "", + "passkey_verified": "", + "redirecting_back_to_app": "", + "redirect_close_instructions": "", + "redirect_again": "", + "autogenerated_first_album_name": "", + "autogenerated_default_album_name": "" +} diff --git a/web/packages/next/locales/bg-BG/translation.json b/web/packages/next/locales/bg-BG/translation.json index 17b0e8bc95..d3b154c357 100644 --- a/web/packages/next/locales/bg-BG/translation.json +++ b/web/packages/next/locales/bg-BG/translation.json @@ -24,9 +24,9 @@ "EXPIRED_CODE": "", "SENDING": "", "SENT": "", - "PASSWORD": "", - "LINK_PASSWORD": "", - "RETURN_PASSPHRASE_HINT": "", + "password": "", + "link_password_description": "", + "unlock": "", "SET_PASSPHRASE": "", "VERIFY_PASSPHRASE": "", "INCORRECT_PASSPHRASE": "", @@ -85,6 +85,7 @@ "NEXT": "", "title_photos": "", "title_auth": "", + "title_accounts": "", "UPLOAD_FIRST_PHOTO": "", "IMPORT_YOUR_FOLDERS": "", "UPLOAD_DROPZONE_MESSAGE": "", @@ -380,7 +381,7 @@ "MANAGE_LINK": "", "LINK_TOO_MANY_REQUESTS": "", "FILE_DOWNLOAD": "", - "LINK_PASSWORD_LOCK": "", + "link_password_lock": "", "PUBLIC_COLLECT": "", "LINK_DEVICE_LIMIT": "", "NO_DEVICE_LIMIT": "", @@ -608,20 +609,35 @@ "FREEHAND": "", "APPLY_CROP": "", "PHOTO_EDIT_REQUIRED_TO_SAVE": "", - "PASSKEYS": "", - "DELETE_PASSKEY": "", - "DELETE_PASSKEY_CONFIRMATION": "", - "RENAME_PASSKEY": "", - "ADD_PASSKEY": "", - "ENTER_PASSKEY_NAME": "", - "PASSKEYS_DESCRIPTION": "", + "passkeys": "", + "passkey_fetch_failed": "", + "manage_passkey": "", + "delete_passkey": "", + "delete_passkey_confirmation": "", + "rename_passkey": "", + "add_passkey": "", + "enter_passkey_name": "", + "passkeys_description": "", "CREATED_AT": "", - "PASSKEY_LOGIN_FAILED": "", - "PASSKEY_LOGIN_URL_INVALID": "", - "PASSKEY_LOGIN_ERRORED": "", - "TRY_AGAIN": "", - "PASSKEY_FOLLOW_THE_STEPS_FROM_YOUR_BROWSER": "", - "LOGIN_WITH_PASSKEY": "", + "passkey_add_failed": "", + "passkey_login_failed": "", + "passkey_login_invalid_url": "", + "passkey_login_already_claimed_session": "", + "passkey_login_generic_error": "", + "passkey_login_credential_hint": "", + "passkeys_not_supported": "", + "try_again": "", + "check_status": "", + "passkey_login_instructions": "", + "passkey_login": "", + "passkey": "", + "passkey_verify_description": "", + "waiting_for_verification": "", + "verification_still_pending": "", + "passkey_verified": "", + "redirecting_back_to_app": "", + "redirect_close_instructions": "", + "redirect_again": "", "autogenerated_first_album_name": "", "autogenerated_default_album_name": "" } diff --git a/web/packages/next/locales/de-DE/translation.json b/web/packages/next/locales/de-DE/translation.json index a3b9bf7d02..72dae50d42 100644 --- a/web/packages/next/locales/de-DE/translation.json +++ b/web/packages/next/locales/de-DE/translation.json @@ -24,9 +24,9 @@ "EXPIRED_CODE": "Ihr Bestätigungscode ist abgelaufen", "SENDING": "Wird gesendet...", "SENT": "Gesendet!", - "PASSWORD": "Passwort", - "LINK_PASSWORD": "Passwort zum Entsperren des Albums eingeben", - "RETURN_PASSPHRASE_HINT": "Passwort", + "password": "Passwort", + "link_password_description": "Passwort zum Entsperren des Albums eingeben", + "unlock": "Freischalten", "SET_PASSPHRASE": "Passwort setzen", "VERIFY_PASSPHRASE": "Einloggen", "INCORRECT_PASSPHRASE": "Falsches Passwort", @@ -85,6 +85,7 @@ "NEXT": "Weitere (→)", "title_photos": "Ente Fotos", "title_auth": "Ente Auth", + "title_accounts": "Ente Konten", "UPLOAD_FIRST_PHOTO": "Lade dein erstes Foto hoch", "IMPORT_YOUR_FOLDERS": "Importiere deiner Ordner", "UPLOAD_DROPZONE_MESSAGE": "Loslassen, um Dateien zu sichern", @@ -380,7 +381,7 @@ "MANAGE_LINK": "Link verwalten", "LINK_TOO_MANY_REQUESTS": "Sorry, dieses Album wurde auf zu vielen Geräten angezeigt!", "FILE_DOWNLOAD": "Downloads erlauben", - "LINK_PASSWORD_LOCK": "Passwort Sperre", + "link_password_lock": "Passwort Sperre", "PUBLIC_COLLECT": "Hinzufügen von Fotos erlauben", "LINK_DEVICE_LIMIT": "Geräte Limit", "NO_DEVICE_LIMIT": "Keins", @@ -565,7 +566,7 @@ "VIDEO": "Video", "LIVE_PHOTO": "Live-Foto", "editor": { - "crop": "" + "crop": "Zuschneiden" }, "CONVERT": "Konvertieren", "CONFIRM_EDITOR_CLOSE_MESSAGE": "Editor wirklich schließen?", @@ -608,20 +609,35 @@ "FREEHAND": "Freihand", "APPLY_CROP": "Zuschnitt anwenden", "PHOTO_EDIT_REQUIRED_TO_SAVE": "Es muss mindestens eine Transformation oder Farbanpassung vorgenommen werden, bevor gespeichert werden kann.", - "PASSKEYS": "Passkeys", - "DELETE_PASSKEY": "Passkey löschen", - "DELETE_PASSKEY_CONFIRMATION": "Bist du sicher, dass du diesen Passkey löschen willst? Dieser Vorgang ist nicht umkehrbar.", - "RENAME_PASSKEY": "Passkey umbenennen", - "ADD_PASSKEY": "Passkey hinzufügen", - "ENTER_PASSKEY_NAME": "Passkey-Namen eingeben", - "PASSKEYS_DESCRIPTION": "Passkeys sind ein moderner und sicherer zweiter Faktor für dein Ente-Konto. Sie nutzen die biometrische Authentifizierung des Geräts für Komfort und Sicherheit.", + "passkeys": "Passkeys", + "passkey_fetch_failed": "Ihre Passkeys konnten nicht abgerufen werden.", + "manage_passkey": "Passkey verwalten", + "delete_passkey": "Passkey löschen", + "delete_passkey_confirmation": "Bist du sicher, dass du diesen Passkey löschen willst? Dieser Vorgang ist nicht umkehrbar.", + "rename_passkey": "Passkey umbenennen", + "add_passkey": "Passkey hinzufügen", + "enter_passkey_name": "Passkey-Namen eingeben", + "passkeys_description": "Passkeys sind ein moderner und sicherer zweiter Faktor für dein Ente-Konto. Sie nutzen die biometrische Authentifizierung des Geräts für Komfort und Sicherheit.", "CREATED_AT": "Erstellt am", - "PASSKEY_LOGIN_FAILED": "Passkey-Anmeldung fehlgeschlagen", - "PASSKEY_LOGIN_URL_INVALID": "Die Anmelde-URL ist ungültig.", - "PASSKEY_LOGIN_ERRORED": "Ein Fehler trat auf beim Anmelden mit dem Passkey auf.", - "TRY_AGAIN": "Erneut versuchen", - "PASSKEY_FOLLOW_THE_STEPS_FROM_YOUR_BROWSER": "Folge den Schritten in deinem Browser, um mit dem Anmelden fortzufahren.", - "LOGIN_WITH_PASSKEY": "Mit Passkey anmelden", + "passkey_add_failed": "Ein Passkey konnte nicht hinzugefügt werden", + "passkey_login_failed": "Passkey-Anmeldung fehlgeschlagen", + "passkey_login_invalid_url": "Die Anmelde-URL ist ungültig.", + "passkey_login_already_claimed_session": "", + "passkey_login_generic_error": "Ein Fehler trat auf beim Anmelden mit dem Passkey auf.", + "passkey_login_credential_hint": "", + "passkeys_not_supported": "Passkeys werden in diesem Browser nicht unterstützt", + "try_again": "Erneut versuchen", + "check_status": "Status überprüfen", + "passkey_login_instructions": "Folge den Schritten in deinem Browser, um mit dem Anmelden fortzufahren.", + "passkey_login": "Mit Passkey anmelden", + "passkey": "Passkey", + "passkey_verify_description": "", + "waiting_for_verification": "Warte auf Bestätigung...", + "verification_still_pending": "Verifizierung steht noch aus", + "passkey_verified": "Passwort verifiziert", + "redirecting_back_to_app": "Sie werden zurück zur App weitergeleitet...", + "redirect_close_instructions": "Sie werden zurück zur App weitergeleitet.", + "redirect_again": "", "autogenerated_first_album_name": "Mein erstes Album", "autogenerated_default_album_name": "Neues Album" } diff --git a/web/packages/next/locales/en-US/translation.json b/web/packages/next/locales/en-US/translation.json index cd0062c6ff..c5508e2424 100644 --- a/web/packages/next/locales/en-US/translation.json +++ b/web/packages/next/locales/en-US/translation.json @@ -24,9 +24,9 @@ "EXPIRED_CODE": "Your verification code has expired", "SENDING": "Sending...", "SENT": "Sent!", - "PASSWORD": "Password", - "LINK_PASSWORD": "Enter password to unlock the album", - "RETURN_PASSPHRASE_HINT": "Password", + "password": "Password", + "link_password_description": "Enter password to unlock the album", + "unlock": "Unlock", "SET_PASSPHRASE": "Set password", "VERIFY_PASSPHRASE": "Sign in", "INCORRECT_PASSPHRASE": "Incorrect password", @@ -85,6 +85,7 @@ "NEXT": "Next (→)", "title_photos": "Ente Photos", "title_auth": "Ente Auth", + "title_accounts": "Ente Accounts", "UPLOAD_FIRST_PHOTO": "Upload your first photo", "IMPORT_YOUR_FOLDERS": "Import your folders", "UPLOAD_DROPZONE_MESSAGE": "Drop to backup your files", @@ -380,7 +381,7 @@ "MANAGE_LINK": "Manage link", "LINK_TOO_MANY_REQUESTS": "Sorry, this album has been viewed on too many devices!", "FILE_DOWNLOAD": "Allow downloads", - "LINK_PASSWORD_LOCK": "Password lock", + "link_password_lock": "Password lock", "PUBLIC_COLLECT": "Allow adding photos", "LINK_DEVICE_LIMIT": "Device limit", "NO_DEVICE_LIMIT": "None", @@ -608,20 +609,35 @@ "FREEHAND": "Freehand", "APPLY_CROP": "Apply Crop", "PHOTO_EDIT_REQUIRED_TO_SAVE": "At least one transformation or color adjustment must be performed before saving.", - "PASSKEYS": "Passkeys", - "DELETE_PASSKEY": "Delete passkey", - "DELETE_PASSKEY_CONFIRMATION": "Are you sure you want to delete this passkey? This action is irreversible.", - "RENAME_PASSKEY": "Rename passkey", - "ADD_PASSKEY": "Add passkey", - "ENTER_PASSKEY_NAME": "Enter passkey name", - "PASSKEYS_DESCRIPTION": "Passkeys are a modern and secure second-factor for your Ente account. They use on-device biometric authentication for convenience and security.", + "passkeys": "Passkeys", + "passkey_fetch_failed": "Could not get your passkeys.", + "manage_passkey": "Manage passkey", + "delete_passkey": "Delete passkey", + "delete_passkey_confirmation": "Are you sure you want to delete this passkey? This action is irreversible.", + "rename_passkey": "Rename passkey", + "add_passkey": "Add passkey", + "enter_passkey_name": "Enter passkey name", + "passkeys_description": "Passkeys are a modern and secure second-factor for your Ente account. They use on-device biometric authentication for convenience and security.", "CREATED_AT": "Created at", - "PASSKEY_LOGIN_FAILED": "Passkey login failed", - "PASSKEY_LOGIN_URL_INVALID": "The login URL is invalid.", - "PASSKEY_LOGIN_ERRORED": "An error occurred while logging in with passkey.", - "TRY_AGAIN": "Try again", - "PASSKEY_FOLLOW_THE_STEPS_FROM_YOUR_BROWSER": "Follow the steps from your browser to continue logging in.", - "LOGIN_WITH_PASSKEY": "Login with passkey", + "passkey_add_failed": "Could not add passkey", + "passkey_login_failed": "Passkey login failed", + "passkey_login_invalid_url": "The login URL is invalid.", + "passkey_login_already_claimed_session": "This session has already been verified.", + "passkey_login_generic_error": "An error occurred while logging in with passkey.", + "passkey_login_credential_hint": "If your passkeys are on a different device, you can open this page on that device to verify.", + "passkeys_not_supported": "Passkeys are not supported in this browser", + "try_again": "Try again", + "check_status": "Check status", + "passkey_login_instructions": "Follow the steps from your browser to continue logging in.", + "passkey_login": "Login with passkey", + "passkey": "Passkey", + "passkey_verify_description": "Verify your passkey to login into your account.", + "waiting_for_verification": "Waiting for verification...", + "verification_still_pending": "Verification is still pending", + "passkey_verified": "Passkey verified", + "redirecting_back_to_app": "Redirecting you back to the app...", + "redirect_close_instructions": "You can close this window after the app opens.", + "redirect_again": "Redirect again", "autogenerated_first_album_name": "My First Album", "autogenerated_default_album_name": "New Album" } diff --git a/web/packages/next/locales/es-ES/translation.json b/web/packages/next/locales/es-ES/translation.json index 216fcd0c0f..1f5284e628 100644 --- a/web/packages/next/locales/es-ES/translation.json +++ b/web/packages/next/locales/es-ES/translation.json @@ -24,9 +24,9 @@ "EXPIRED_CODE": "Código de verificación expirado", "SENDING": "Enviando...", "SENT": "Enviado!", - "PASSWORD": "Contraseña", - "LINK_PASSWORD": "Introducir contraseña para desbloquear el álbum", - "RETURN_PASSPHRASE_HINT": "Contraseña", + "password": "Contraseña", + "link_password_description": "Introducir contraseña para desbloquear el álbum", + "unlock": "", "SET_PASSPHRASE": "Definir contraseña", "VERIFY_PASSPHRASE": "Ingresar", "INCORRECT_PASSPHRASE": "Contraseña incorrecta", @@ -85,6 +85,7 @@ "NEXT": "Siguiente (→)", "title_photos": "ente Fotos", "title_auth": "ente Auth", + "title_accounts": "", "UPLOAD_FIRST_PHOTO": "Carga tu primer archivo", "IMPORT_YOUR_FOLDERS": "Importar tus carpetas", "UPLOAD_DROPZONE_MESSAGE": "Soltar para respaldar tus archivos", @@ -380,7 +381,7 @@ "MANAGE_LINK": "Administrar enlace", "LINK_TOO_MANY_REQUESTS": "Este álbum es demasiado popular para que podamos manejarlo!", "FILE_DOWNLOAD": "Permitir descargas", - "LINK_PASSWORD_LOCK": "Contraseña bloqueada", + "link_password_lock": "Contraseña bloqueada", "PUBLIC_COLLECT": "Permitir añadir fotos", "LINK_DEVICE_LIMIT": "Límites del dispositivo", "NO_DEVICE_LIMIT": "Ninguno", @@ -608,20 +609,35 @@ "FREEHAND": "", "APPLY_CROP": "", "PHOTO_EDIT_REQUIRED_TO_SAVE": "", - "PASSKEYS": "", - "DELETE_PASSKEY": "", - "DELETE_PASSKEY_CONFIRMATION": "", - "RENAME_PASSKEY": "", - "ADD_PASSKEY": "", - "ENTER_PASSKEY_NAME": "", - "PASSKEYS_DESCRIPTION": "", + "passkeys": "", + "passkey_fetch_failed": "", + "manage_passkey": "", + "delete_passkey": "", + "delete_passkey_confirmation": "", + "rename_passkey": "", + "add_passkey": "", + "enter_passkey_name": "", + "passkeys_description": "", "CREATED_AT": "", - "PASSKEY_LOGIN_FAILED": "", - "PASSKEY_LOGIN_URL_INVALID": "", - "PASSKEY_LOGIN_ERRORED": "", - "TRY_AGAIN": "Inténtelo de nuevo", - "PASSKEY_FOLLOW_THE_STEPS_FROM_YOUR_BROWSER": "", - "LOGIN_WITH_PASSKEY": "", + "passkey_add_failed": "", + "passkey_login_failed": "", + "passkey_login_invalid_url": "", + "passkey_login_already_claimed_session": "", + "passkey_login_generic_error": "", + "passkey_login_credential_hint": "", + "passkeys_not_supported": "", + "try_again": "Inténtelo de nuevo", + "check_status": "", + "passkey_login_instructions": "", + "passkey_login": "", + "passkey": "", + "passkey_verify_description": "", + "waiting_for_verification": "", + "verification_still_pending": "", + "passkey_verified": "", + "redirecting_back_to_app": "", + "redirect_close_instructions": "", + "redirect_again": "", "autogenerated_first_album_name": "", "autogenerated_default_album_name": "" } diff --git a/web/packages/next/locales/fa-IR/translation.json b/web/packages/next/locales/fa-IR/translation.json index 4546dfbd49..c5fa97ced8 100644 --- a/web/packages/next/locales/fa-IR/translation.json +++ b/web/packages/next/locales/fa-IR/translation.json @@ -24,9 +24,9 @@ "EXPIRED_CODE": "", "SENDING": "", "SENT": "", - "PASSWORD": "", - "LINK_PASSWORD": "", - "RETURN_PASSPHRASE_HINT": "", + "password": "", + "link_password_description": "", + "unlock": "", "SET_PASSPHRASE": "", "VERIFY_PASSPHRASE": "", "INCORRECT_PASSPHRASE": "", @@ -85,6 +85,7 @@ "NEXT": "", "title_photos": "", "title_auth": "", + "title_accounts": "", "UPLOAD_FIRST_PHOTO": "", "IMPORT_YOUR_FOLDERS": "", "UPLOAD_DROPZONE_MESSAGE": "", @@ -380,7 +381,7 @@ "MANAGE_LINK": "", "LINK_TOO_MANY_REQUESTS": "", "FILE_DOWNLOAD": "", - "LINK_PASSWORD_LOCK": "", + "link_password_lock": "", "PUBLIC_COLLECT": "", "LINK_DEVICE_LIMIT": "", "NO_DEVICE_LIMIT": "", @@ -608,20 +609,35 @@ "FREEHAND": "", "APPLY_CROP": "", "PHOTO_EDIT_REQUIRED_TO_SAVE": "", - "PASSKEYS": "", - "DELETE_PASSKEY": "", - "DELETE_PASSKEY_CONFIRMATION": "", - "RENAME_PASSKEY": "", - "ADD_PASSKEY": "", - "ENTER_PASSKEY_NAME": "", - "PASSKEYS_DESCRIPTION": "", + "passkeys": "", + "passkey_fetch_failed": "", + "manage_passkey": "", + "delete_passkey": "", + "delete_passkey_confirmation": "", + "rename_passkey": "", + "add_passkey": "", + "enter_passkey_name": "", + "passkeys_description": "", "CREATED_AT": "", - "PASSKEY_LOGIN_FAILED": "", - "PASSKEY_LOGIN_URL_INVALID": "", - "PASSKEY_LOGIN_ERRORED": "", - "TRY_AGAIN": "", - "PASSKEY_FOLLOW_THE_STEPS_FROM_YOUR_BROWSER": "", - "LOGIN_WITH_PASSKEY": "", + "passkey_add_failed": "", + "passkey_login_failed": "", + "passkey_login_invalid_url": "", + "passkey_login_already_claimed_session": "", + "passkey_login_generic_error": "", + "passkey_login_credential_hint": "", + "passkeys_not_supported": "", + "try_again": "", + "check_status": "", + "passkey_login_instructions": "", + "passkey_login": "", + "passkey": "", + "passkey_verify_description": "", + "waiting_for_verification": "", + "verification_still_pending": "", + "passkey_verified": "", + "redirecting_back_to_app": "", + "redirect_close_instructions": "", + "redirect_again": "", "autogenerated_first_album_name": "", "autogenerated_default_album_name": "" } diff --git a/web/packages/next/locales/fi-FI/translation.json b/web/packages/next/locales/fi-FI/translation.json index 0750f1fbfc..8c268c2c86 100644 --- a/web/packages/next/locales/fi-FI/translation.json +++ b/web/packages/next/locales/fi-FI/translation.json @@ -24,9 +24,9 @@ "EXPIRED_CODE": "", "SENDING": "", "SENT": "", - "PASSWORD": "", - "LINK_PASSWORD": "", - "RETURN_PASSPHRASE_HINT": "", + "password": "", + "link_password_description": "", + "unlock": "", "SET_PASSPHRASE": "", "VERIFY_PASSPHRASE": "", "INCORRECT_PASSPHRASE": "", @@ -85,6 +85,7 @@ "NEXT": "", "title_photos": "", "title_auth": "", + "title_accounts": "", "UPLOAD_FIRST_PHOTO": "", "IMPORT_YOUR_FOLDERS": "", "UPLOAD_DROPZONE_MESSAGE": "", @@ -380,7 +381,7 @@ "MANAGE_LINK": "", "LINK_TOO_MANY_REQUESTS": "", "FILE_DOWNLOAD": "", - "LINK_PASSWORD_LOCK": "", + "link_password_lock": "", "PUBLIC_COLLECT": "", "LINK_DEVICE_LIMIT": "", "NO_DEVICE_LIMIT": "", @@ -608,20 +609,35 @@ "FREEHAND": "", "APPLY_CROP": "", "PHOTO_EDIT_REQUIRED_TO_SAVE": "", - "PASSKEYS": "", - "DELETE_PASSKEY": "", - "DELETE_PASSKEY_CONFIRMATION": "", - "RENAME_PASSKEY": "", - "ADD_PASSKEY": "", - "ENTER_PASSKEY_NAME": "", - "PASSKEYS_DESCRIPTION": "", + "passkeys": "", + "passkey_fetch_failed": "", + "manage_passkey": "", + "delete_passkey": "", + "delete_passkey_confirmation": "", + "rename_passkey": "", + "add_passkey": "", + "enter_passkey_name": "", + "passkeys_description": "", "CREATED_AT": "", - "PASSKEY_LOGIN_FAILED": "", - "PASSKEY_LOGIN_URL_INVALID": "", - "PASSKEY_LOGIN_ERRORED": "", - "TRY_AGAIN": "", - "PASSKEY_FOLLOW_THE_STEPS_FROM_YOUR_BROWSER": "", - "LOGIN_WITH_PASSKEY": "", + "passkey_add_failed": "", + "passkey_login_failed": "", + "passkey_login_invalid_url": "", + "passkey_login_already_claimed_session": "", + "passkey_login_generic_error": "", + "passkey_login_credential_hint": "", + "passkeys_not_supported": "", + "try_again": "", + "check_status": "", + "passkey_login_instructions": "", + "passkey_login": "", + "passkey": "", + "passkey_verify_description": "", + "waiting_for_verification": "", + "verification_still_pending": "", + "passkey_verified": "", + "redirecting_back_to_app": "", + "redirect_close_instructions": "", + "redirect_again": "", "autogenerated_first_album_name": "", "autogenerated_default_album_name": "" } diff --git a/web/packages/next/locales/fr-FR/translation.json b/web/packages/next/locales/fr-FR/translation.json index d3ef5f578f..05e38c5349 100644 --- a/web/packages/next/locales/fr-FR/translation.json +++ b/web/packages/next/locales/fr-FR/translation.json @@ -24,9 +24,9 @@ "EXPIRED_CODE": "Votre code de vérification a expiré", "SENDING": "Envoi...", "SENT": "Envoyé!", - "PASSWORD": "Mot de passe", - "LINK_PASSWORD": "Saisir le mot de passe pour déverrouiller l'album", - "RETURN_PASSPHRASE_HINT": "Mot de passe", + "password": "Mot de passe", + "link_password_description": "Saisir le mot de passe pour déverrouiller l'album", + "unlock": "Déverrouiller", "SET_PASSPHRASE": "Définir le mot de passe", "VERIFY_PASSPHRASE": "Connexion", "INCORRECT_PASSPHRASE": "Mot de passe non valide", @@ -85,6 +85,7 @@ "NEXT": "Suivant (→)", "title_photos": "Ente Photos", "title_auth": "Ente Auth", + "title_accounts": "Comptes Ente", "UPLOAD_FIRST_PHOTO": "Chargez votre 1ere photo", "IMPORT_YOUR_FOLDERS": "Importez vos dossiers", "UPLOAD_DROPZONE_MESSAGE": "Déposez pour sauvegarder vos fichiers", @@ -380,7 +381,7 @@ "MANAGE_LINK": "Gérer le lien", "LINK_TOO_MANY_REQUESTS": "Désolé, cet album a été consulté sur trop d'appareils !", "FILE_DOWNLOAD": "Autoriser les téléchargements", - "LINK_PASSWORD_LOCK": "Verrou par mot de passe", + "link_password_lock": "Verrou par mot de passe", "PUBLIC_COLLECT": "Autoriser l'ajout de photos", "LINK_DEVICE_LIMIT": "Limite d'appareil", "NO_DEVICE_LIMIT": "Aucune", @@ -608,20 +609,35 @@ "FREEHAND": "Main levée", "APPLY_CROP": "Appliquer le recadrage", "PHOTO_EDIT_REQUIRED_TO_SAVE": "Au moins une transformation ou un ajustement de couleur doit être effectué avant de sauvegarder.", - "PASSKEYS": "Clés d'accès", - "DELETE_PASSKEY": "Supprimer le code d'accès", - "DELETE_PASSKEY_CONFIRMATION": "Êtes-vous sûr de vouloir supprimer ce code ? Cette action est irréversible.", - "RENAME_PASSKEY": "Renommer le code d'accès", - "ADD_PASSKEY": "Ajouter un code d'accès", - "ENTER_PASSKEY_NAME": "Entrez le nom du code d'accès", - "PASSKEYS_DESCRIPTION": "Les codes d'ccès sont un deuxième facteur moderne et sécurisé pour votre compte Ente. Ils utilisent l'authentification biométrique de l'appareil pour des raisons de commodité et de sécurité.", + "passkeys": "Clés d'accès", + "passkey_fetch_failed": "", + "manage_passkey": "", + "delete_passkey": "Supprimer le code d'accès", + "delete_passkey_confirmation": "Êtes-vous sûr de vouloir supprimer ce code ? Cette action est irréversible.", + "rename_passkey": "Renommer le code d'accès", + "add_passkey": "Ajouter un code d'accès", + "enter_passkey_name": "Entrez le nom du code d'accès", + "passkeys_description": "Les codes d'ccès sont un deuxième facteur moderne et sécurisé pour votre compte Ente. Ils utilisent l'authentification biométrique de l'appareil pour des raisons de commodité et de sécurité.", "CREATED_AT": "Créé le", - "PASSKEY_LOGIN_FAILED": "Échec de la connexion via code d'accès", - "PASSKEY_LOGIN_URL_INVALID": "L’URL de connexion est invalide.", - "PASSKEY_LOGIN_ERRORED": "Une erreur s'est produite lors de la connexion avec le code d'accès.", - "TRY_AGAIN": "Réessayer", - "PASSKEY_FOLLOW_THE_STEPS_FROM_YOUR_BROWSER": "Suivez les étapes de votre navigateur pour poursuivre la connexion.", - "LOGIN_WITH_PASSKEY": "Se connecter avec le code d'accès", + "passkey_add_failed": "", + "passkey_login_failed": "Échec de la connexion via code d'accès", + "passkey_login_invalid_url": "L’URL de connexion est invalide.", + "passkey_login_already_claimed_session": "", + "passkey_login_generic_error": "Une erreur s'est produite lors de la connexion avec le code d'accès.", + "passkey_login_credential_hint": "", + "passkeys_not_supported": "", + "try_again": "Réessayer", + "check_status": "", + "passkey_login_instructions": "Suivez les étapes de votre navigateur pour poursuivre la connexion.", + "passkey_login": "Se connecter avec le code d'accès", + "passkey": "", + "passkey_verify_description": "", + "waiting_for_verification": "", + "verification_still_pending": "", + "passkey_verified": "Vérification du code d'accès", + "redirecting_back_to_app": "Redirection vers l'application...", + "redirect_close_instructions": "Vous pouvez fermer cette fenêtre après l'ouverture de l'application.", + "redirect_again": "Rediriger à nouveau", "autogenerated_first_album_name": "Mon premier album", "autogenerated_default_album_name": "Nouvel album" } diff --git a/web/packages/next/locales/id-ID/translation.json b/web/packages/next/locales/id-ID/translation.json new file mode 100644 index 0000000000..9d3b24b8cc --- /dev/null +++ b/web/packages/next/locales/id-ID/translation.json @@ -0,0 +1,643 @@ +{ + "HERO_SLIDE_1_TITLE": "", + "HERO_SLIDE_1": "", + "HERO_SLIDE_2_TITLE": "", + "HERO_SLIDE_2": "", + "HERO_SLIDE_3_TITLE": "
    Tersedia
    di mana saja
    ", + "HERO_SLIDE_3": "Android, iOS, Web, Desktop", + "LOGIN": "", + "SIGN_UP": "", + "NEW_USER": "", + "EXISTING_USER": "", + "ENTER_NAME": "", + "PUBLIC_UPLOADER_NAME_MESSAGE": "", + "ENTER_EMAIL": "", + "EMAIL_ERROR": "", + "REQUIRED": "", + "EMAIL_SENT": "", + "CHECK_INBOX": "", + "ENTER_OTT": "", + "RESEND_MAIL": "", + "VERIFY": "", + "UNKNOWN_ERROR": "", + "INVALID_CODE": "", + "EXPIRED_CODE": "", + "SENDING": "", + "SENT": "", + "password": "", + "link_password_description": "", + "unlock": "", + "SET_PASSPHRASE": "", + "VERIFY_PASSPHRASE": "", + "INCORRECT_PASSPHRASE": "", + "ENTER_ENC_PASSPHRASE": "", + "PASSPHRASE_DISCLAIMER": "", + "WELCOME_TO_ENTE_HEADING": "", + "WELCOME_TO_ENTE_SUBHEADING": "", + "WHERE_YOUR_BEST_PHOTOS_LIVE": "", + "KEY_GENERATION_IN_PROGRESS_MESSAGE": "", + "PASSPHRASE_HINT": "", + "CONFIRM_PASSPHRASE": "", + "REFERRAL_CODE_HINT": "", + "REFERRAL_INFO": "", + "PASSPHRASE_MATCH_ERROR": "", + "CREATE_COLLECTION": "", + "ENTER_ALBUM_NAME": "", + "CLOSE_OPTION": "", + "ENTER_FILE_NAME": "", + "CLOSE": "", + "NO": "", + "NOTHING_HERE": "", + "UPLOAD": "", + "IMPORT": "", + "ADD_PHOTOS": "", + "ADD_MORE_PHOTOS": "", + "add_photos_one": "", + "add_photos_other": "", + "SELECT_PHOTOS": "", + "FILE_UPLOAD": "", + "UPLOAD_STAGE_MESSAGE": { + "0": "", + "1": "", + "2": "", + "3": "", + "4": "", + "5": "" + }, + "FILE_NOT_UPLOADED_LIST": "", + "SUBSCRIPTION_EXPIRED": "", + "SUBSCRIPTION_EXPIRED_MESSAGE": "", + "STORAGE_QUOTA_EXCEEDED": "", + "INITIAL_LOAD_DELAY_WARNING": "", + "USER_DOES_NOT_EXIST": "", + "NO_ACCOUNT": "", + "ACCOUNT_EXISTS": "", + "CREATE": "", + "DOWNLOAD": "", + "DOWNLOAD_OPTION": "", + "DOWNLOAD_FAVORITES": "", + "DOWNLOAD_UNCATEGORIZED": "", + "DOWNLOAD_HIDDEN_ITEMS": "", + "COPY_OPTION": "", + "TOGGLE_FULLSCREEN": "", + "ZOOM_IN_OUT": "", + "PREVIOUS": "", + "NEXT": "", + "title_photos": "", + "title_auth": "", + "title_accounts": "", + "UPLOAD_FIRST_PHOTO": "", + "IMPORT_YOUR_FOLDERS": "", + "UPLOAD_DROPZONE_MESSAGE": "", + "WATCH_FOLDER_DROPZONE_MESSAGE": "", + "TRASH_FILES_TITLE": "", + "TRASH_FILE_TITLE": "", + "DELETE_FILES_TITLE": "", + "DELETE_FILES_MESSAGE": "", + "DELETE": "", + "DELETE_OPTION": "", + "FAVORITE_OPTION": "", + "UNFAVORITE_OPTION": "", + "MULTI_FOLDER_UPLOAD": "", + "UPLOAD_STRATEGY_CHOICE": "", + "UPLOAD_STRATEGY_SINGLE_COLLECTION": "", + "OR": "", + "UPLOAD_STRATEGY_COLLECTION_PER_FOLDER": "", + "SESSION_EXPIRED_MESSAGE": "", + "SESSION_EXPIRED": "", + "PASSWORD_GENERATION_FAILED": "", + "CHANGE_PASSWORD": "", + "GO_BACK": "", + "RECOVERY_KEY": "", + "SAVE_LATER": "", + "SAVE": "", + "RECOVERY_KEY_DESCRIPTION": "", + "RECOVER_KEY_GENERATION_FAILED": "", + "KEY_NOT_STORED_DISCLAIMER": "", + "FORGOT_PASSWORD": "", + "RECOVER_ACCOUNT": "", + "RECOVERY_KEY_HINT": "", + "RECOVER": "", + "NO_RECOVERY_KEY": "", + "INCORRECT_RECOVERY_KEY": "", + "SORRY": "", + "NO_RECOVERY_KEY_MESSAGE": "", + "NO_TWO_FACTOR_RECOVERY_KEY_MESSAGE": "", + "CONTACT_SUPPORT": "", + "REQUEST_FEATURE": "", + "SUPPORT": "", + "CONFIRM": "", + "CANCEL": "", + "LOGOUT": "", + "DELETE_ACCOUNT": "", + "DELETE_ACCOUNT_MESSAGE": "", + "LOGOUT_MESSAGE": "", + "CHANGE_EMAIL": "", + "OK": "", + "SUCCESS": "", + "ERROR": "", + "MESSAGE": "", + "INSTALL_MOBILE_APP": "", + "DOWNLOAD_APP_MESSAGE": "", + "DOWNLOAD_APP": "", + "EXPORT": "", + "SUBSCRIPTION": "", + "SUBSCRIBE": "", + "MANAGEMENT_PORTAL": "", + "MANAGE_FAMILY_PORTAL": "", + "LEAVE_FAMILY_PLAN": "", + "LEAVE": "", + "LEAVE_FAMILY_CONFIRM": "", + "CHOOSE_PLAN": "", + "MANAGE_PLAN": "", + "ACTIVE": "", + "OFFLINE_MSG": "", + "FREE_SUBSCRIPTION_INFO": "", + "FAMILY_SUBSCRIPTION_INFO": "", + "RENEWAL_ACTIVE_SUBSCRIPTION_STATUS": "", + "RENEWAL_CANCELLED_SUBSCRIPTION_STATUS": "", + "RENEWAL_CANCELLED_SUBSCRIPTION_INFO": "", + "ADD_ON_AVAILABLE_TILL": "", + "STORAGE_QUOTA_EXCEEDED_SUBSCRIPTION_INFO": "", + "SUBSCRIPTION_PURCHASE_SUCCESS": "", + "SUBSCRIPTION_PURCHASE_CANCELLED": "", + "SUBSCRIPTION_PURCHASE_FAILED": "", + "SUBSCRIPTION_UPDATE_FAILED": "", + "UPDATE_PAYMENT_METHOD_MESSAGE": "", + "STRIPE_AUTHENTICATION_FAILED": "", + "UPDATE_PAYMENT_METHOD": "", + "MONTHLY": "", + "YEARLY": "", + "update_subscription_title": "", + "UPDATE_SUBSCRIPTION_MESSAGE": "", + "UPDATE_SUBSCRIPTION": "", + "CANCEL_SUBSCRIPTION": "", + "CANCEL_SUBSCRIPTION_MESSAGE": "", + "CANCEL_SUBSCRIPTION_WITH_ADDON_MESSAGE": "", + "SUBSCRIPTION_CANCEL_FAILED": "", + "SUBSCRIPTION_CANCEL_SUCCESS": "", + "REACTIVATE_SUBSCRIPTION": "", + "REACTIVATE_SUBSCRIPTION_MESSAGE": "", + "SUBSCRIPTION_ACTIVATE_SUCCESS": "", + "SUBSCRIPTION_ACTIVATE_FAILED": "", + "SUBSCRIPTION_PURCHASE_SUCCESS_TITLE": "", + "CANCEL_SUBSCRIPTION_ON_MOBILE": "", + "CANCEL_SUBSCRIPTION_ON_MOBILE_MESSAGE": "", + "MAIL_TO_MANAGE_SUBSCRIPTION": "", + "RENAME": "", + "RENAME_FILE": "", + "RENAME_COLLECTION": "", + "DELETE_COLLECTION_TITLE": "", + "DELETE_COLLECTION": "", + "DELETE_COLLECTION_MESSAGE": "", + "DELETE_PHOTOS": "", + "KEEP_PHOTOS": "", + "SHARE_COLLECTION": "", + "SHARE_WITH_SELF": "", + "ALREADY_SHARED": "", + "SHARING_BAD_REQUEST_ERROR": "", + "SHARING_DISABLED_FOR_FREE_ACCOUNTS": "", + "DOWNLOAD_COLLECTION": "", + "CREATE_ALBUM_FAILED": "", + "SEARCH": "", + "SEARCH_RESULTS": "", + "NO_RESULTS": "", + "SEARCH_HINT": "", + "SEARCH_TYPE": { + "COLLECTION": "", + "LOCATION": "", + "CITY": "", + "DATE": "", + "FILE_NAME": "", + "THING": "", + "FILE_CAPTION": "", + "FILE_TYPE": "", + "CLIP": "" + }, + "photos_count_zero": "", + "photos_count_one": "", + "photos_count_other": "", + "TERMS_AND_CONDITIONS": "", + "ADD_TO_COLLECTION": "", + "SELECTED": "", + "PEOPLE": "", + "INDEXING_SCHEDULED": "", + "ANALYZING_PHOTOS": "", + "INDEXING_PEOPLE": "", + "INDEXING_DONE": "", + "UNIDENTIFIED_FACES": "", + "OBJECTS": "", + "TEXT": "", + "INFO": "", + "INFO_OPTION": "", + "FILE_NAME": "", + "CAPTION_PLACEHOLDER": "", + "LOCATION": "", + "SHOW_ON_MAP": "", + "MAP": "", + "MAP_SETTINGS": "", + "ENABLE_MAPS": "", + "ENABLE_MAP": "", + "DISABLE_MAPS": "", + "ENABLE_MAP_DESCRIPTION": "", + "DISABLE_MAP_DESCRIPTION": "", + "DISABLE_MAP": "", + "DETAILS": "", + "VIEW_EXIF": "", + "NO_EXIF": "", + "EXIF": "", + "ISO": "", + "TWO_FACTOR": "", + "TWO_FACTOR_AUTHENTICATION": "", + "TWO_FACTOR_QR_INSTRUCTION": "", + "ENTER_CODE_MANUALLY": "", + "TWO_FACTOR_MANUAL_CODE_INSTRUCTION": "", + "SCAN_QR_CODE": "", + "ENABLE_TWO_FACTOR": "", + "ENABLE": "", + "LOST_DEVICE": "", + "INCORRECT_CODE": "", + "TWO_FACTOR_INFO": "", + "DISABLE_TWO_FACTOR_LABEL": "", + "UPDATE_TWO_FACTOR_LABEL": "", + "DISABLE": "", + "RECONFIGURE": "", + "UPDATE_TWO_FACTOR": "", + "UPDATE_TWO_FACTOR_MESSAGE": "", + "UPDATE": "", + "DISABLE_TWO_FACTOR": "", + "DISABLE_TWO_FACTOR_MESSAGE": "", + "TWO_FACTOR_DISABLE_FAILED": "", + "EXPORT_DATA": "", + "SELECT_FOLDER": "", + "DESTINATION": "", + "START": "", + "LAST_EXPORT_TIME": "", + "EXPORT_AGAIN": "", + "LOCAL_STORAGE_NOT_ACCESSIBLE": "", + "LOCAL_STORAGE_NOT_ACCESSIBLE_MESSAGE": "", + "SEND_OTT": "", + "EMAIl_ALREADY_OWNED": "", + "ETAGS_BLOCKED": "", + "LIVE_PHOTOS_DETECTED": "", + "RETRY_FAILED": "", + "FAILED_UPLOADS": "", + "SKIPPED_FILES": "", + "THUMBNAIL_GENERATION_FAILED_UPLOADS": "", + "UNSUPPORTED_FILES": "", + "SUCCESSFUL_UPLOADS": "", + "SKIPPED_INFO": "", + "UNSUPPORTED_INFO": "", + "BLOCKED_UPLOADS": "", + "INPROGRESS_METADATA_EXTRACTION": "", + "INPROGRESS_UPLOADS": "", + "TOO_LARGE_UPLOADS": "", + "LARGER_THAN_AVAILABLE_STORAGE_UPLOADS": "", + "LARGER_THAN_AVAILABLE_STORAGE_INFO": "", + "TOO_LARGE_INFO": "", + "THUMBNAIL_GENERATION_FAILED_INFO": "", + "UPLOAD_TO_COLLECTION": "", + "UNCATEGORIZED": "", + "ARCHIVE": "", + "FAVORITES": "", + "ARCHIVE_COLLECTION": "", + "ARCHIVE_SECTION_NAME": "", + "ALL_SECTION_NAME": "", + "MOVE_TO_COLLECTION": "", + "UNARCHIVE": "", + "UNARCHIVE_COLLECTION": "", + "HIDE_COLLECTION": "", + "UNHIDE_COLLECTION": "", + "MOVE": "", + "ADD": "", + "REMOVE": "", + "YES_REMOVE": "", + "REMOVE_FROM_COLLECTION": "", + "TRASH": "", + "MOVE_TO_TRASH": "", + "TRASH_FILES_MESSAGE": "", + "TRASH_FILE_MESSAGE": "", + "DELETE_PERMANENTLY": "", + "RESTORE": "", + "RESTORE_TO_COLLECTION": "", + "EMPTY_TRASH": "", + "EMPTY_TRASH_TITLE": "", + "EMPTY_TRASH_MESSAGE": "", + "LEAVE_SHARED_ALBUM": "", + "LEAVE_ALBUM": "", + "LEAVE_SHARED_ALBUM_TITLE": "", + "LEAVE_SHARED_ALBUM_MESSAGE": "", + "NOT_FILE_OWNER": "", + "CONFIRM_SELF_REMOVE_MESSAGE": "", + "CONFIRM_SELF_AND_OTHER_REMOVE_MESSAGE": "", + "SORT_BY_CREATION_TIME_ASCENDING": "", + "SORT_BY_UPDATION_TIME_DESCENDING": "", + "SORT_BY_NAME": "", + "FIX_CREATION_TIME": "", + "FIX_CREATION_TIME_IN_PROGRESS": "", + "CREATION_TIME_UPDATED": "", + "UPDATE_CREATION_TIME_NOT_STARTED": "", + "UPDATE_CREATION_TIME_COMPLETED": "", + "UPDATE_CREATION_TIME_COMPLETED_WITH_ERROR": "", + "CAPTION_CHARACTER_LIMIT": "", + "DATE_TIME_ORIGINAL": "", + "DATE_TIME_DIGITIZED": "", + "METADATA_DATE": "", + "CUSTOM_TIME": "", + "REOPEN_PLAN_SELECTOR_MODAL": "", + "OPEN_PLAN_SELECTOR_MODAL_FAILED": "", + "INSTALL": "", + "SHARING_DETAILS": "", + "MODIFY_SHARING": "", + "ADD_COLLABORATORS": "", + "ADD_NEW_EMAIL": "", + "shared_with_people_zero": "", + "shared_with_people_one": "", + "shared_with_people_other": "", + "participants_zero": "", + "participants_one": "", + "participants_other": "", + "ADD_VIEWERS": "", + "CHANGE_PERMISSIONS_TO_VIEWER": "", + "CHANGE_PERMISSIONS_TO_COLLABORATOR": "", + "CONVERT_TO_VIEWER": "", + "CONVERT_TO_COLLABORATOR": "", + "CHANGE_PERMISSION": "", + "REMOVE_PARTICIPANT": "", + "CONFIRM_REMOVE": "", + "MANAGE": "", + "ADDED_AS": "", + "COLLABORATOR_RIGHTS": "", + "REMOVE_PARTICIPANT_HEAD": "", + "OWNER": "", + "COLLABORATORS": "", + "ADD_MORE": "", + "VIEWERS": "", + "OR_ADD_EXISTING": "", + "REMOVE_PARTICIPANT_MESSAGE": "", + "NOT_FOUND": "", + "LINK_EXPIRED": "", + "LINK_EXPIRED_MESSAGE": "", + "MANAGE_LINK": "", + "LINK_TOO_MANY_REQUESTS": "", + "FILE_DOWNLOAD": "", + "link_password_lock": "", + "PUBLIC_COLLECT": "", + "LINK_DEVICE_LIMIT": "", + "NO_DEVICE_LIMIT": "", + "LINK_EXPIRY": "", + "NEVER": "", + "DISABLE_FILE_DOWNLOAD": "", + "DISABLE_FILE_DOWNLOAD_MESSAGE": "", + "SHARED_USING": "", + "SHARING_REFERRAL_CODE": "", + "LIVE": "", + "DISABLE_PASSWORD": "", + "DISABLE_PASSWORD_MESSAGE": "", + "PASSWORD_LOCK": "", + "LOCK": "", + "DOWNLOAD_UPLOAD_LOGS": "", + "UPLOAD_FILES": "", + "UPLOAD_DIRS": "", + "UPLOAD_GOOGLE_TAKEOUT": "", + "DEDUPLICATE_FILES": "", + "NO_DUPLICATES_FOUND": "", + "FILES": "", + "EACH": "", + "DEDUPLICATE_BASED_ON_SIZE": "", + "STOP_ALL_UPLOADS_MESSAGE": "", + "STOP_UPLOADS_HEADER": "", + "YES_STOP_UPLOADS": "", + "STOP_DOWNLOADS_HEADER": "", + "YES_STOP_DOWNLOADS": "", + "STOP_ALL_DOWNLOADS_MESSAGE": "", + "albums_one": "", + "albums_other": "", + "ALL_ALBUMS": "", + "ALBUMS": "", + "ALL_HIDDEN_ALBUMS": "", + "HIDDEN_ALBUMS": "", + "HIDDEN_ITEMS": "", + "ENTER_TWO_FACTOR_OTP": "", + "CREATE_ACCOUNT": "", + "COPIED": "", + "WATCH_FOLDERS": "", + "UPGRADE_NOW": "", + "RENEW_NOW": "", + "STORAGE": "", + "USED": "", + "YOU": "", + "FAMILY": "", + "FREE": "", + "OF": "", + "WATCHED_FOLDERS": "", + "NO_FOLDERS_ADDED": "", + "FOLDERS_AUTOMATICALLY_MONITORED": "", + "UPLOAD_NEW_FILES_TO_ENTE": "", + "REMOVE_DELETED_FILES_FROM_ENTE": "", + "ADD_FOLDER": "", + "STOP_WATCHING": "", + "STOP_WATCHING_FOLDER": "", + "STOP_WATCHING_DIALOG_MESSAGE": "", + "YES_STOP": "", + "MONTH_SHORT": "", + "YEAR": "", + "FAMILY_PLAN": "", + "DOWNLOAD_LOGS": "", + "DOWNLOAD_LOGS_MESSAGE": "", + "CHANGE_FOLDER": "", + "TWO_MONTHS_FREE": "", + "POPULAR": "", + "FREE_PLAN_OPTION_LABEL": "", + "free_plan_description": "", + "CURRENT_USAGE": "", + "WEAK_DEVICE": "", + "DRAG_AND_DROP_HINT": "", + "CONFIRM_ACCOUNT_DELETION_MESSAGE": "", + "AUTHENTICATE": "", + "UPLOADED_TO_SINGLE_COLLECTION": "", + "UPLOADED_TO_SEPARATE_COLLECTIONS": "", + "NEVERMIND": "", + "UPDATE_AVAILABLE": "", + "UPDATE_INSTALLABLE_MESSAGE": "", + "INSTALL_NOW": "", + "INSTALL_ON_NEXT_LAUNCH": "", + "UPDATE_AVAILABLE_MESSAGE": "", + "DOWNLOAD_AND_INSTALL": "", + "IGNORE_THIS_VERSION": "", + "TODAY": "", + "YESTERDAY": "", + "NAME_PLACEHOLDER": "", + "ROOT_LEVEL_FILE_WITH_FOLDER_NOT_ALLOWED": "", + "ROOT_LEVEL_FILE_WITH_FOLDER_NOT_ALLOWED_MESSAGE": "", + "CHOSE_THEME": "", + "ML_SEARCH": "", + "ENABLE_ML_SEARCH_DESCRIPTION": "", + "ML_MORE_DETAILS": "", + "ENABLE_FACE_SEARCH": "", + "ENABLE_FACE_SEARCH_TITLE": "", + "ENABLE_FACE_SEARCH_DESCRIPTION": "", + "DISABLE_BETA": "", + "DISABLE_FACE_SEARCH": "", + "DISABLE_FACE_SEARCH_TITLE": "", + "DISABLE_FACE_SEARCH_DESCRIPTION": "", + "ADVANCED": "", + "FACE_SEARCH_CONFIRMATION": "", + "LABS": "", + "YOURS": "", + "PASSPHRASE_STRENGTH_WEAK": "", + "PASSPHRASE_STRENGTH_MODERATE": "", + "PASSPHRASE_STRENGTH_STRONG": "", + "PREFERENCES": "", + "LANGUAGE": "", + "EXPORT_DIRECTORY_DOES_NOT_EXIST": "", + "EXPORT_DIRECTORY_DOES_NOT_EXIST_MESSAGE": "", + "SUBSCRIPTION_VERIFICATION_ERROR": "", + "storage_unit": { + "b": "", + "kb": "", + "mb": "", + "gb": "", + "tb": "" + }, + "AFTER_TIME": { + "HOUR": "", + "DAY": "", + "WEEK": "", + "MONTH": "", + "YEAR": "" + }, + "COPY_LINK": "", + "DONE": "", + "LINK_SHARE_TITLE": "", + "REMOVE_LINK": "", + "CREATE_PUBLIC_SHARING": "", + "PUBLIC_LINK_CREATED": "", + "PUBLIC_LINK_ENABLED": "", + "COLLECT_PHOTOS": "", + "PUBLIC_COLLECT_SUBTEXT": "", + "STOP_EXPORT": "", + "EXPORT_PROGRESS": "", + "MIGRATING_EXPORT": "", + "RENAMING_COLLECTION_FOLDERS": "", + "TRASHING_DELETED_FILES": "", + "TRASHING_DELETED_COLLECTIONS": "", + "CONTINUOUS_EXPORT": "", + "PENDING_ITEMS": "", + "EXPORT_STARTING": "", + "DELETE_ACCOUNT_REASON_LABEL": "", + "DELETE_ACCOUNT_REASON_PLACEHOLDER": "", + "DELETE_REASON": { + "MISSING_FEATURE": "", + "BROKEN_BEHAVIOR": "", + "FOUND_ANOTHER_SERVICE": "", + "NOT_LISTED": "" + }, + "DELETE_ACCOUNT_FEEDBACK_LABEL": "", + "DELETE_ACCOUNT_FEEDBACK_PLACEHOLDER": "", + "CONFIRM_DELETE_ACCOUNT_CHECKBOX_LABEL": "", + "CONFIRM_DELETE_ACCOUNT": "", + "FEEDBACK_REQUIRED": "", + "FEEDBACK_REQUIRED_FOUND_ANOTHER_SERVICE": "", + "RECOVER_TWO_FACTOR": "", + "at": "", + "AUTH_NEXT": "", + "AUTH_DOWNLOAD_MOBILE_APP": "", + "HIDDEN": "", + "HIDE": "", + "UNHIDE": "", + "UNHIDE_TO_COLLECTION": "", + "SORT_BY": "", + "NEWEST_FIRST": "", + "OLDEST_FIRST": "", + "CONVERSION_FAILED_NOTIFICATION_MESSAGE": "", + "SELECT_COLLECTION": "", + "PIN_ALBUM": "", + "UNPIN_ALBUM": "", + "DOWNLOAD_COMPLETE": "", + "DOWNLOADING_COLLECTION": "", + "DOWNLOAD_FAILED": "", + "DOWNLOAD_PROGRESS": "", + "CHRISTMAS": "", + "CHRISTMAS_EVE": "", + "NEW_YEAR": "", + "NEW_YEAR_EVE": "", + "IMAGE": "", + "VIDEO": "", + "LIVE_PHOTO": "", + "editor": { + "crop": "" + }, + "CONVERT": "", + "CONFIRM_EDITOR_CLOSE_MESSAGE": "", + "CONFIRM_EDITOR_CLOSE_DESCRIPTION": "", + "BRIGHTNESS": "", + "CONTRAST": "", + "SATURATION": "", + "BLUR": "", + "INVERT_COLORS": "", + "ASPECT_RATIO": "", + "SQUARE": "", + "ROTATE_LEFT": "", + "ROTATE_RIGHT": "", + "FLIP_VERTICALLY": "", + "FLIP_HORIZONTALLY": "", + "DOWNLOAD_EDITED": "", + "SAVE_A_COPY_TO_ENTE": "", + "RESTORE_ORIGINAL": "", + "TRANSFORM": "", + "COLORS": "", + "FLIP": "", + "ROTATION": "", + "RESET": "", + "PHOTO_EDITOR": "", + "FASTER_UPLOAD": "", + "FASTER_UPLOAD_DESCRIPTION": "", + "MAGIC_SEARCH_STATUS": "", + "INDEXED_ITEMS": "", + "CAST_ALBUM_TO_TV": "", + "ENTER_CAST_PIN_CODE": "", + "PAIR_DEVICE_TO_TV": "", + "TV_NOT_FOUND": "", + "AUTO_CAST_PAIR": "", + "AUTO_CAST_PAIR_DESC": "", + "PAIR_WITH_PIN": "", + "CHOOSE_DEVICE_FROM_BROWSER": "", + "PAIR_WITH_PIN_DESC": "", + "VISIT_CAST_ENTE_IO": "", + "CAST_AUTO_PAIR_FAILED": "", + "FREEHAND": "", + "APPLY_CROP": "", + "PHOTO_EDIT_REQUIRED_TO_SAVE": "", + "passkeys": "", + "passkey_fetch_failed": "", + "manage_passkey": "", + "delete_passkey": "", + "delete_passkey_confirmation": "", + "rename_passkey": "", + "add_passkey": "", + "enter_passkey_name": "", + "passkeys_description": "", + "CREATED_AT": "", + "passkey_add_failed": "", + "passkey_login_failed": "", + "passkey_login_invalid_url": "", + "passkey_login_already_claimed_session": "", + "passkey_login_generic_error": "", + "passkey_login_credential_hint": "", + "passkeys_not_supported": "", + "try_again": "", + "check_status": "", + "passkey_login_instructions": "", + "passkey_login": "", + "passkey": "", + "passkey_verify_description": "", + "waiting_for_verification": "", + "verification_still_pending": "", + "passkey_verified": "", + "redirecting_back_to_app": "", + "redirect_close_instructions": "", + "redirect_again": "", + "autogenerated_first_album_name": "", + "autogenerated_default_album_name": "" +} diff --git a/web/packages/next/locales/is-IS/translation.json b/web/packages/next/locales/is-IS/translation.json index 1d5d66da7e..245c69f07e 100644 --- a/web/packages/next/locales/is-IS/translation.json +++ b/web/packages/next/locales/is-IS/translation.json @@ -24,9 +24,9 @@ "EXPIRED_CODE": "", "SENDING": "", "SENT": "", - "PASSWORD": "Lykilorð", - "LINK_PASSWORD": "", - "RETURN_PASSPHRASE_HINT": "Lykilorð", + "password": "Lykilorð", + "link_password_description": "", + "unlock": "", "SET_PASSPHRASE": "", "VERIFY_PASSPHRASE": "", "INCORRECT_PASSPHRASE": "Rangt lykilorð", @@ -85,6 +85,7 @@ "NEXT": "", "title_photos": "", "title_auth": "", + "title_accounts": "", "UPLOAD_FIRST_PHOTO": "", "IMPORT_YOUR_FOLDERS": "", "UPLOAD_DROPZONE_MESSAGE": "", @@ -380,7 +381,7 @@ "MANAGE_LINK": "Stjórna hlekk", "LINK_TOO_MANY_REQUESTS": "", "FILE_DOWNLOAD": "", - "LINK_PASSWORD_LOCK": "", + "link_password_lock": "", "PUBLIC_COLLECT": "", "LINK_DEVICE_LIMIT": "", "NO_DEVICE_LIMIT": "", @@ -608,20 +609,35 @@ "FREEHAND": "", "APPLY_CROP": "", "PHOTO_EDIT_REQUIRED_TO_SAVE": "", - "PASSKEYS": "", - "DELETE_PASSKEY": "", - "DELETE_PASSKEY_CONFIRMATION": "", - "RENAME_PASSKEY": "", - "ADD_PASSKEY": "", - "ENTER_PASSKEY_NAME": "", - "PASSKEYS_DESCRIPTION": "", + "passkeys": "", + "passkey_fetch_failed": "", + "manage_passkey": "", + "delete_passkey": "", + "delete_passkey_confirmation": "", + "rename_passkey": "", + "add_passkey": "", + "enter_passkey_name": "", + "passkeys_description": "", "CREATED_AT": "", - "PASSKEY_LOGIN_FAILED": "", - "PASSKEY_LOGIN_URL_INVALID": "", - "PASSKEY_LOGIN_ERRORED": "", - "TRY_AGAIN": "", - "PASSKEY_FOLLOW_THE_STEPS_FROM_YOUR_BROWSER": "", - "LOGIN_WITH_PASSKEY": "", + "passkey_add_failed": "", + "passkey_login_failed": "", + "passkey_login_invalid_url": "", + "passkey_login_already_claimed_session": "", + "passkey_login_generic_error": "", + "passkey_login_credential_hint": "", + "passkeys_not_supported": "", + "try_again": "", + "check_status": "", + "passkey_login_instructions": "", + "passkey_login": "", + "passkey": "", + "passkey_verify_description": "", + "waiting_for_verification": "", + "verification_still_pending": "", + "passkey_verified": "", + "redirecting_back_to_app": "", + "redirect_close_instructions": "", + "redirect_again": "", "autogenerated_first_album_name": "", "autogenerated_default_album_name": "" } diff --git a/web/packages/next/locales/it-IT/translation.json b/web/packages/next/locales/it-IT/translation.json index 13f5daf746..788de9cbf8 100644 --- a/web/packages/next/locales/it-IT/translation.json +++ b/web/packages/next/locales/it-IT/translation.json @@ -24,9 +24,9 @@ "EXPIRED_CODE": "Il tuo codice di verifica è scaduto", "SENDING": "Invio in corso...", "SENT": "Inviato!", - "PASSWORD": "Password", - "LINK_PASSWORD": "Inserisci la password per sbloccare l'album", - "RETURN_PASSPHRASE_HINT": "Password", + "password": "", + "link_password_description": "Inserisci la password per sbloccare l'album", + "unlock": "", "SET_PASSPHRASE": "Imposta una password", "VERIFY_PASSPHRASE": "Accedi", "INCORRECT_PASSPHRASE": "Password sbagliata", @@ -85,6 +85,7 @@ "NEXT": "Successivo (→)", "title_photos": "", "title_auth": "", + "title_accounts": "", "UPLOAD_FIRST_PHOTO": "Carica la tua prima foto", "IMPORT_YOUR_FOLDERS": "Importa una cartella", "UPLOAD_DROPZONE_MESSAGE": "Rilascia per eseguire il backup dei file", @@ -380,7 +381,7 @@ "MANAGE_LINK": "", "LINK_TOO_MANY_REQUESTS": "", "FILE_DOWNLOAD": "", - "LINK_PASSWORD_LOCK": "", + "link_password_lock": "", "PUBLIC_COLLECT": "", "LINK_DEVICE_LIMIT": "", "NO_DEVICE_LIMIT": "", @@ -608,20 +609,35 @@ "FREEHAND": "", "APPLY_CROP": "", "PHOTO_EDIT_REQUIRED_TO_SAVE": "", - "PASSKEYS": "", - "DELETE_PASSKEY": "", - "DELETE_PASSKEY_CONFIRMATION": "", - "RENAME_PASSKEY": "", - "ADD_PASSKEY": "", - "ENTER_PASSKEY_NAME": "", - "PASSKEYS_DESCRIPTION": "", + "passkeys": "", + "passkey_fetch_failed": "", + "manage_passkey": "", + "delete_passkey": "", + "delete_passkey_confirmation": "", + "rename_passkey": "", + "add_passkey": "", + "enter_passkey_name": "", + "passkeys_description": "", "CREATED_AT": "", - "PASSKEY_LOGIN_FAILED": "", - "PASSKEY_LOGIN_URL_INVALID": "", - "PASSKEY_LOGIN_ERRORED": "", - "TRY_AGAIN": "", - "PASSKEY_FOLLOW_THE_STEPS_FROM_YOUR_BROWSER": "", - "LOGIN_WITH_PASSKEY": "", + "passkey_add_failed": "", + "passkey_login_failed": "", + "passkey_login_invalid_url": "", + "passkey_login_already_claimed_session": "", + "passkey_login_generic_error": "", + "passkey_login_credential_hint": "", + "passkeys_not_supported": "", + "try_again": "", + "check_status": "", + "passkey_login_instructions": "", + "passkey_login": "", + "passkey": "", + "passkey_verify_description": "", + "waiting_for_verification": "", + "verification_still_pending": "", + "passkey_verified": "", + "redirecting_back_to_app": "", + "redirect_close_instructions": "", + "redirect_again": "", "autogenerated_first_album_name": "", "autogenerated_default_album_name": "" } diff --git a/web/packages/next/locales/ko-KR/translation.json b/web/packages/next/locales/ko-KR/translation.json index 48daba6237..9988374412 100644 --- a/web/packages/next/locales/ko-KR/translation.json +++ b/web/packages/next/locales/ko-KR/translation.json @@ -24,9 +24,9 @@ "EXPIRED_CODE": "입력한 인증 코드가 만료되었습니다", "SENDING": "전송 중...", "SENT": "발송 완료!", - "PASSWORD": "비밀번호", - "LINK_PASSWORD": "앨범 잠금해제를 위해 비밀번호를 입력하세요", - "RETURN_PASSPHRASE_HINT": "비밀번호", + "password": "비밀번호", + "link_password_description": "앨범 잠금해제를 위해 비밀번호를 입력하세요", + "unlock": "", "SET_PASSPHRASE": "비밀번호 설정", "VERIFY_PASSPHRASE": "로그인", "INCORRECT_PASSPHRASE": "잘못된 비밀번호입니다", @@ -85,6 +85,7 @@ "NEXT": "다음 항목 (→)", "title_photos": "Ente Photos", "title_auth": "Ente Auth", + "title_accounts": "", "UPLOAD_FIRST_PHOTO": "첫 번째 사진을 업로드하세요", "IMPORT_YOUR_FOLDERS": "폴더 가져오기", "UPLOAD_DROPZONE_MESSAGE": "백업하려는 파일을 올려놓기", @@ -380,7 +381,7 @@ "MANAGE_LINK": "", "LINK_TOO_MANY_REQUESTS": "", "FILE_DOWNLOAD": "", - "LINK_PASSWORD_LOCK": "", + "link_password_lock": "", "PUBLIC_COLLECT": "", "LINK_DEVICE_LIMIT": "", "NO_DEVICE_LIMIT": "", @@ -608,20 +609,35 @@ "FREEHAND": "", "APPLY_CROP": "", "PHOTO_EDIT_REQUIRED_TO_SAVE": "", - "PASSKEYS": "", - "DELETE_PASSKEY": "", - "DELETE_PASSKEY_CONFIRMATION": "", - "RENAME_PASSKEY": "", - "ADD_PASSKEY": "", - "ENTER_PASSKEY_NAME": "", - "PASSKEYS_DESCRIPTION": "", + "passkeys": "", + "passkey_fetch_failed": "", + "manage_passkey": "", + "delete_passkey": "", + "delete_passkey_confirmation": "", + "rename_passkey": "", + "add_passkey": "", + "enter_passkey_name": "", + "passkeys_description": "", "CREATED_AT": "", - "PASSKEY_LOGIN_FAILED": "", - "PASSKEY_LOGIN_URL_INVALID": "", - "PASSKEY_LOGIN_ERRORED": "", - "TRY_AGAIN": "", - "PASSKEY_FOLLOW_THE_STEPS_FROM_YOUR_BROWSER": "", - "LOGIN_WITH_PASSKEY": "", + "passkey_add_failed": "", + "passkey_login_failed": "", + "passkey_login_invalid_url": "", + "passkey_login_already_claimed_session": "", + "passkey_login_generic_error": "", + "passkey_login_credential_hint": "", + "passkeys_not_supported": "", + "try_again": "", + "check_status": "", + "passkey_login_instructions": "", + "passkey_login": "", + "passkey": "", + "passkey_verify_description": "", + "waiting_for_verification": "", + "verification_still_pending": "", + "passkey_verified": "", + "redirecting_back_to_app": "", + "redirect_close_instructions": "", + "redirect_again": "", "autogenerated_first_album_name": "", "autogenerated_default_album_name": "" } diff --git a/web/packages/next/locales/nl-NL/translation.json b/web/packages/next/locales/nl-NL/translation.json index f3c0c510d3..13c2f0c61c 100644 --- a/web/packages/next/locales/nl-NL/translation.json +++ b/web/packages/next/locales/nl-NL/translation.json @@ -24,9 +24,9 @@ "EXPIRED_CODE": "Uw verificatiecode is verlopen", "SENDING": "Verzenden...", "SENT": "Verzonden!", - "PASSWORD": "Wachtwoord", - "LINK_PASSWORD": "Voer wachtwoord in om het album te ontgrendelen", - "RETURN_PASSPHRASE_HINT": "Wachtwoord", + "password": "Wachtwoord", + "link_password_description": "Voer wachtwoord in om het album te ontgrendelen", + "unlock": "", "SET_PASSPHRASE": "Wachtwoord instellen", "VERIFY_PASSPHRASE": "Aanmelden", "INCORRECT_PASSPHRASE": "Onjuist wachtwoord", @@ -85,6 +85,7 @@ "NEXT": "Volgende (→)", "title_photos": "Ente Foto's", "title_auth": "Ente Auth", + "title_accounts": "", "UPLOAD_FIRST_PHOTO": "Je eerste foto uploaden", "IMPORT_YOUR_FOLDERS": "Importeer uw mappen", "UPLOAD_DROPZONE_MESSAGE": "Sleep om een back-up van je bestanden te maken", @@ -380,7 +381,7 @@ "MANAGE_LINK": "Link beheren", "LINK_TOO_MANY_REQUESTS": "Dit album is te populair voor ons om te verwerken!", "FILE_DOWNLOAD": "Downloads toestaan", - "LINK_PASSWORD_LOCK": "Wachtwoord versleuteling", + "link_password_lock": "Wachtwoord versleuteling", "PUBLIC_COLLECT": "Foto's toevoegen toestaan", "LINK_DEVICE_LIMIT": "Apparaat limiet", "NO_DEVICE_LIMIT": "Geen", @@ -608,20 +609,35 @@ "FREEHAND": "Losse hand", "APPLY_CROP": "Bijsnijden toepassen", "PHOTO_EDIT_REQUIRED_TO_SAVE": "Tenminste één transformatie of kleuraanpassing moet worden uitgevoerd voordat u opslaat.", - "PASSKEYS": "Passkeys", - "DELETE_PASSKEY": "Passkeys verwijderen", - "DELETE_PASSKEY_CONFIRMATION": "Weet je zeker dat je deze passkey wilt verwijderen? Deze actie is onomkeerbaar.", - "RENAME_PASSKEY": "Hernoem passkeys", - "ADD_PASSKEY": "Passkeys toevoegen", - "ENTER_PASSKEY_NAME": "Voer passkey naam in", - "PASSKEYS_DESCRIPTION": "Passkeys zijn een moderne en veilige tweede factor beveiliging voor je Ente account. Ze gebruiken biometrische authenticatie op je apparaat voor gemak en veiligheid.", + "passkeys": "Passkeys", + "passkey_fetch_failed": "", + "manage_passkey": "", + "delete_passkey": "Passkeys verwijderen", + "delete_passkey_confirmation": "Weet je zeker dat je deze passkey wilt verwijderen? Deze actie is onomkeerbaar.", + "rename_passkey": "Hernoem passkeys", + "add_passkey": "Passkeys toevoegen", + "enter_passkey_name": "Voer passkey naam in", + "passkeys_description": "Passkeys zijn een moderne en veilige tweede factor beveiliging voor je Ente account. Ze gebruiken biometrische authenticatie op je apparaat voor gemak en veiligheid.", "CREATED_AT": "Aangemaakt op", - "PASSKEY_LOGIN_FAILED": "Passkey login mislukt", - "PASSKEY_LOGIN_URL_INVALID": "De inlog-URL is ongeldig.", - "PASSKEY_LOGIN_ERRORED": "Er is een fout opgetreden tijdens het inloggen met een passkey.", - "TRY_AGAIN": "Probeer opnieuw", - "PASSKEY_FOLLOW_THE_STEPS_FROM_YOUR_BROWSER": "Volg de stappen van je browser om door te gaan met inloggen.", - "LOGIN_WITH_PASSKEY": "Inloggen met passkey", + "passkey_add_failed": "", + "passkey_login_failed": "Passkey login mislukt", + "passkey_login_invalid_url": "De inlog-URL is ongeldig.", + "passkey_login_already_claimed_session": "", + "passkey_login_generic_error": "Er is een fout opgetreden tijdens het inloggen met een passkey.", + "passkey_login_credential_hint": "", + "passkeys_not_supported": "", + "try_again": "Probeer opnieuw", + "check_status": "", + "passkey_login_instructions": "Volg de stappen van je browser om door te gaan met inloggen.", + "passkey_login": "Inloggen met passkey", + "passkey": "", + "passkey_verify_description": "", + "waiting_for_verification": "", + "verification_still_pending": "", + "passkey_verified": "", + "redirecting_back_to_app": "", + "redirect_close_instructions": "", + "redirect_again": "", "autogenerated_first_album_name": "Mijn eerste album", "autogenerated_default_album_name": "Nieuw album" } diff --git a/web/packages/next/locales/pt-BR/translation.json b/web/packages/next/locales/pt-BR/translation.json index 006aad0183..c2cddad29f 100644 --- a/web/packages/next/locales/pt-BR/translation.json +++ b/web/packages/next/locales/pt-BR/translation.json @@ -24,9 +24,9 @@ "EXPIRED_CODE": "O seu código de verificação expirou", "SENDING": "Enviando...", "SENT": "Enviado!", - "PASSWORD": "Senha", - "LINK_PASSWORD": "Insira a senha para desbloquear o álbum", - "RETURN_PASSPHRASE_HINT": "Senha", + "password": "Senha", + "link_password_description": "Insira a senha para desbloquear o álbum", + "unlock": "Desbloquear", "SET_PASSPHRASE": "Definir senha", "VERIFY_PASSPHRASE": "Iniciar sessão", "INCORRECT_PASSPHRASE": "Senha incorreta", @@ -85,6 +85,7 @@ "NEXT": "Próximo (→)", "title_photos": "Ente Fotos", "title_auth": "Ente Auth", + "title_accounts": "Contas Ente", "UPLOAD_FIRST_PHOTO": "Envie sua primeira foto", "IMPORT_YOUR_FOLDERS": "Importar suas pastas", "UPLOAD_DROPZONE_MESSAGE": "Arraste para salvar seus arquivos", @@ -380,7 +381,7 @@ "MANAGE_LINK": "Gerenciar link", "LINK_TOO_MANY_REQUESTS": "Desculpe, este álbum foi visualizado em muitos dispositivos!", "FILE_DOWNLOAD": "Permitir downloads", - "LINK_PASSWORD_LOCK": "Bloqueio de senha", + "link_password_lock": "Bloqueio de senha", "PUBLIC_COLLECT": "Permitir adicionar fotos", "LINK_DEVICE_LIMIT": "Limite de dispositivos", "NO_DEVICE_LIMIT": "Nenhum", @@ -608,20 +609,35 @@ "FREEHAND": "Mão livre", "APPLY_CROP": "Aplicar Recorte", "PHOTO_EDIT_REQUIRED_TO_SAVE": "Pelo menos uma transformação ou ajuste de cor deve ser feito antes de salvar.", - "PASSKEYS": "Chaves de acesso", - "DELETE_PASSKEY": "Excluir chave de acesso", - "DELETE_PASSKEY_CONFIRMATION": "Tem certeza de que deseja excluir esta chave de acesso? Esta ação é irreversível.", - "RENAME_PASSKEY": "Renomear chave de acesso", - "ADD_PASSKEY": "Adicionar chave de acesso", - "ENTER_PASSKEY_NAME": "Inserir nome da chave de acesso", - "PASSKEYS_DESCRIPTION": "As chaves de acesso são um moderno e seguro segundo fator de sua conta Ente. Elas usam a autenticação biométrica no dispositivo para fins de conveniência e segurança.", + "passkeys": "Chaves de acesso", + "passkey_fetch_failed": "Não foi possível obter suas chaves de acesso.", + "manage_passkey": "Gerenciar chave de acesso", + "delete_passkey": "Excluir chave de acesso", + "delete_passkey_confirmation": "Tem certeza de que deseja excluir esta chave de acesso? Esta ação é irreversível.", + "rename_passkey": "Renomear chave de acesso", + "add_passkey": "Adicionar chave de acesso", + "enter_passkey_name": "Inserir nome da chave de acesso", + "passkeys_description": "As chaves de acesso são um moderno e seguro segundo fator de sua conta Ente. Elas usam a autenticação biométrica no dispositivo para fins de conveniência e segurança.", "CREATED_AT": "Criado em", - "PASSKEY_LOGIN_FAILED": "Falha ao iniciar sessão com a chave de acesso", - "PASSKEY_LOGIN_URL_INVALID": "URL de login inválida.", - "PASSKEY_LOGIN_ERRORED": "Ocorreu um erro ao entrar com a chave de acesso.", - "TRY_AGAIN": "Tente novamente", - "PASSKEY_FOLLOW_THE_STEPS_FROM_YOUR_BROWSER": "Siga os passos do seu navegador para continuar acessando.", - "LOGIN_WITH_PASSKEY": "Entrar com a chave de acesso", + "passkey_add_failed": "Não foi possível adicionar chave de acesso", + "passkey_login_failed": "Falha ao iniciar sessão com a chave de acesso", + "passkey_login_invalid_url": "URL de login inválida.", + "passkey_login_already_claimed_session": "", + "passkey_login_generic_error": "Ocorreu um erro ao entrar com a chave de acesso.", + "passkey_login_credential_hint": "", + "passkeys_not_supported": "As chaves de acesso não são suportadas neste navegador", + "try_again": "Tente novamente", + "check_status": "", + "passkey_login_instructions": "Siga os passos do seu navegador para continuar acessando.", + "passkey_login": "Entrar com a chave de acesso", + "passkey": "", + "passkey_verify_description": "", + "waiting_for_verification": "", + "verification_still_pending": "", + "passkey_verified": "Chave de acesso verificada", + "redirecting_back_to_app": "Redirecionando você de volta para o aplicativo...", + "redirect_close_instructions": "Você pode fechar esta janela após a aplicação ser aberta.", + "redirect_again": "Redirecionar novamente", "autogenerated_first_album_name": "Meu Primeiro Álbum", "autogenerated_default_album_name": "Novo Álbum" } diff --git a/web/packages/next/locales/pt-PT/translation.json b/web/packages/next/locales/pt-PT/translation.json index db77fad3f2..d4263c3863 100644 --- a/web/packages/next/locales/pt-PT/translation.json +++ b/web/packages/next/locales/pt-PT/translation.json @@ -24,9 +24,9 @@ "EXPIRED_CODE": "O seu código de verificação expirou", "SENDING": "A enviar...", "SENT": "Enviado!", - "PASSWORD": "Palavra-passe", - "LINK_PASSWORD": "Introduza a palavra-passe para desbloquear o álbum", - "RETURN_PASSPHRASE_HINT": "Palavra-passe", + "password": "Palavra-passe", + "link_password_description": "Introduza a palavra-passe para desbloquear o álbum", + "unlock": "", "SET_PASSPHRASE": "Definir palavra-passe", "VERIFY_PASSPHRASE": "Entrar", "INCORRECT_PASSPHRASE": "Palavra-passe incorreta", @@ -85,6 +85,7 @@ "NEXT": "", "title_photos": "", "title_auth": "", + "title_accounts": "", "UPLOAD_FIRST_PHOTO": "", "IMPORT_YOUR_FOLDERS": "", "UPLOAD_DROPZONE_MESSAGE": "", @@ -380,7 +381,7 @@ "MANAGE_LINK": "", "LINK_TOO_MANY_REQUESTS": "", "FILE_DOWNLOAD": "", - "LINK_PASSWORD_LOCK": "", + "link_password_lock": "", "PUBLIC_COLLECT": "", "LINK_DEVICE_LIMIT": "", "NO_DEVICE_LIMIT": "", @@ -608,20 +609,35 @@ "FREEHAND": "", "APPLY_CROP": "", "PHOTO_EDIT_REQUIRED_TO_SAVE": "", - "PASSKEYS": "", - "DELETE_PASSKEY": "", - "DELETE_PASSKEY_CONFIRMATION": "", - "RENAME_PASSKEY": "", - "ADD_PASSKEY": "", - "ENTER_PASSKEY_NAME": "", - "PASSKEYS_DESCRIPTION": "", + "passkeys": "", + "passkey_fetch_failed": "", + "manage_passkey": "", + "delete_passkey": "", + "delete_passkey_confirmation": "", + "rename_passkey": "", + "add_passkey": "", + "enter_passkey_name": "", + "passkeys_description": "", "CREATED_AT": "", - "PASSKEY_LOGIN_FAILED": "", - "PASSKEY_LOGIN_URL_INVALID": "", - "PASSKEY_LOGIN_ERRORED": "", - "TRY_AGAIN": "", - "PASSKEY_FOLLOW_THE_STEPS_FROM_YOUR_BROWSER": "", - "LOGIN_WITH_PASSKEY": "", + "passkey_add_failed": "", + "passkey_login_failed": "", + "passkey_login_invalid_url": "", + "passkey_login_already_claimed_session": "", + "passkey_login_generic_error": "", + "passkey_login_credential_hint": "", + "passkeys_not_supported": "", + "try_again": "", + "check_status": "", + "passkey_login_instructions": "", + "passkey_login": "", + "passkey": "", + "passkey_verify_description": "", + "waiting_for_verification": "", + "verification_still_pending": "", + "passkey_verified": "", + "redirecting_back_to_app": "", + "redirect_close_instructions": "", + "redirect_again": "", "autogenerated_first_album_name": "", "autogenerated_default_album_name": "" } diff --git a/web/packages/next/locales/ru-RU/translation.json b/web/packages/next/locales/ru-RU/translation.json index 87d1e631f5..1d399aa3f1 100644 --- a/web/packages/next/locales/ru-RU/translation.json +++ b/web/packages/next/locales/ru-RU/translation.json @@ -24,9 +24,9 @@ "EXPIRED_CODE": "Срок действия вашего проверочного кода истек", "SENDING": "Отправка...", "SENT": "Отправлено!", - "PASSWORD": "Пароль", - "LINK_PASSWORD": "Введите пароль, чтобы разблокировать альбом", - "RETURN_PASSPHRASE_HINT": "Пароль", + "password": "Пароль", + "link_password_description": "Введите пароль, чтобы разблокировать альбом", + "unlock": "", "SET_PASSPHRASE": "Установить пароль", "VERIFY_PASSPHRASE": "Войти", "INCORRECT_PASSPHRASE": "Неверный пароль", @@ -85,6 +85,7 @@ "NEXT": "Следующий (→)", "title_photos": "Ente Фото", "title_auth": "Ente Auth", + "title_accounts": "", "UPLOAD_FIRST_PHOTO": "Загрузите своё первое фото", "IMPORT_YOUR_FOLDERS": "Импортируйте папки", "UPLOAD_DROPZONE_MESSAGE": "Перетащите для резервного копирования файлов", @@ -380,7 +381,7 @@ "MANAGE_LINK": "Управление ссылкой", "LINK_TOO_MANY_REQUESTS": "Извините, этот альбом был просмотрен на слишком большом количестве устройств!", "FILE_DOWNLOAD": "Разрешить загрузку", - "LINK_PASSWORD_LOCK": "Блокировка паролем", + "link_password_lock": "Блокировка паролем", "PUBLIC_COLLECT": "Разрешить добавление фотографий", "LINK_DEVICE_LIMIT": "Предел устройства", "NO_DEVICE_LIMIT": "Никто", @@ -608,20 +609,35 @@ "FREEHAND": "От руки", "APPLY_CROP": "Применить обрезку", "PHOTO_EDIT_REQUIRED_TO_SAVE": "Перед сохранением необходимо выполнить по крайней мере одно преобразование или корректировку цвета.", - "PASSKEYS": "Пароли доступа", - "DELETE_PASSKEY": "Удалить пароль", - "DELETE_PASSKEY_CONFIRMATION": "Вы уверены, что хотите удалить этот пароль? Это действие необратимо.", - "RENAME_PASSKEY": "Переименовать пароль", - "ADD_PASSKEY": "Добавить пароль", - "ENTER_PASSKEY_NAME": "Введите имя ключа доступа", - "PASSKEYS_DESCRIPTION": "Пароли доступа - это современный и безопасный дополнительный фактор для вашей учетной записи Ente. Для удобства и безопасности они используют биометрическую аутентификацию на устройстве.", + "passkeys": "Пароли доступа", + "passkey_fetch_failed": "", + "manage_passkey": "", + "delete_passkey": "Удалить пароль", + "delete_passkey_confirmation": "Вы уверены, что хотите удалить этот пароль? Это действие необратимо.", + "rename_passkey": "Переименовать пароль", + "add_passkey": "Добавить пароль", + "enter_passkey_name": "Введите имя ключа доступа", + "passkeys_description": "Пароли доступа - это современный и безопасный дополнительный фактор для вашей учетной записи Ente. Для удобства и безопасности они используют биометрическую аутентификацию на устройстве.", "CREATED_AT": "Созданный в", - "PASSKEY_LOGIN_FAILED": "Не удалось войти с помощью пароля", - "PASSKEY_LOGIN_URL_INVALID": "Неверный URL-адрес для входа в систему.", - "PASSKEY_LOGIN_ERRORED": "При входе в систему с помощью пароля произошла ошибка.", - "TRY_AGAIN": "Пробовать снова", - "PASSKEY_FOLLOW_THE_STEPS_FROM_YOUR_BROWSER": "Следуйте инструкциям в вашем браузере, чтобы продолжить вход в систему.", - "LOGIN_WITH_PASSKEY": "Войдите в систему с помощью пароля", + "passkey_add_failed": "", + "passkey_login_failed": "Не удалось войти с помощью пароля", + "passkey_login_invalid_url": "Неверный URL-адрес для входа в систему.", + "passkey_login_already_claimed_session": "", + "passkey_login_generic_error": "При входе в систему с помощью пароля произошла ошибка.", + "passkey_login_credential_hint": "", + "passkeys_not_supported": "", + "try_again": "Пробовать снова", + "check_status": "", + "passkey_login_instructions": "Следуйте инструкциям в вашем браузере, чтобы продолжить вход в систему.", + "passkey_login": "Войдите в систему с помощью пароля", + "passkey": "", + "passkey_verify_description": "", + "waiting_for_verification": "", + "verification_still_pending": "", + "passkey_verified": "", + "redirecting_back_to_app": "", + "redirect_close_instructions": "", + "redirect_again": "", "autogenerated_first_album_name": "Мой первый альбом", "autogenerated_default_album_name": "Новый альбом" } diff --git a/web/packages/next/locales/sv-SE/translation.json b/web/packages/next/locales/sv-SE/translation.json index 758d0ee314..4cad6a11bb 100644 --- a/web/packages/next/locales/sv-SE/translation.json +++ b/web/packages/next/locales/sv-SE/translation.json @@ -24,9 +24,9 @@ "EXPIRED_CODE": "Din verifieringskod har löpt ut", "SENDING": "Skickar...", "SENT": "Skickat!", - "PASSWORD": "Lösenord", - "LINK_PASSWORD": "Ange lösenord för att låsa upp albumet", - "RETURN_PASSPHRASE_HINT": "Lösenord", + "password": "Lösenord", + "link_password_description": "Ange lösenord för att låsa upp albumet", + "unlock": "", "SET_PASSPHRASE": "Välj lösenord", "VERIFY_PASSPHRASE": "Logga in", "INCORRECT_PASSPHRASE": "Felaktigt lösenord", @@ -85,6 +85,7 @@ "NEXT": "", "title_photos": "", "title_auth": "", + "title_accounts": "", "UPLOAD_FIRST_PHOTO": "", "IMPORT_YOUR_FOLDERS": "", "UPLOAD_DROPZONE_MESSAGE": "", @@ -380,7 +381,7 @@ "MANAGE_LINK": "Hantera länk", "LINK_TOO_MANY_REQUESTS": "", "FILE_DOWNLOAD": "", - "LINK_PASSWORD_LOCK": "", + "link_password_lock": "", "PUBLIC_COLLECT": "", "LINK_DEVICE_LIMIT": "", "NO_DEVICE_LIMIT": "", @@ -608,20 +609,35 @@ "FREEHAND": "", "APPLY_CROP": "", "PHOTO_EDIT_REQUIRED_TO_SAVE": "", - "PASSKEYS": "", - "DELETE_PASSKEY": "", - "DELETE_PASSKEY_CONFIRMATION": "", - "RENAME_PASSKEY": "", - "ADD_PASSKEY": "", - "ENTER_PASSKEY_NAME": "", - "PASSKEYS_DESCRIPTION": "", + "passkeys": "", + "passkey_fetch_failed": "", + "manage_passkey": "", + "delete_passkey": "", + "delete_passkey_confirmation": "", + "rename_passkey": "", + "add_passkey": "", + "enter_passkey_name": "", + "passkeys_description": "", "CREATED_AT": "", - "PASSKEY_LOGIN_FAILED": "", - "PASSKEY_LOGIN_URL_INVALID": "", - "PASSKEY_LOGIN_ERRORED": "", - "TRY_AGAIN": "Försök igen", - "PASSKEY_FOLLOW_THE_STEPS_FROM_YOUR_BROWSER": "", - "LOGIN_WITH_PASSKEY": "", + "passkey_add_failed": "", + "passkey_login_failed": "", + "passkey_login_invalid_url": "", + "passkey_login_already_claimed_session": "", + "passkey_login_generic_error": "", + "passkey_login_credential_hint": "", + "passkeys_not_supported": "", + "try_again": "Försök igen", + "check_status": "", + "passkey_login_instructions": "", + "passkey_login": "", + "passkey": "", + "passkey_verify_description": "", + "waiting_for_verification": "", + "verification_still_pending": "", + "passkey_verified": "", + "redirecting_back_to_app": "", + "redirect_close_instructions": "", + "redirect_again": "", "autogenerated_first_album_name": "", "autogenerated_default_album_name": "" } diff --git a/web/packages/next/locales/te-IN/translation.json b/web/packages/next/locales/te-IN/translation.json new file mode 100644 index 0000000000..8c268c2c86 --- /dev/null +++ b/web/packages/next/locales/te-IN/translation.json @@ -0,0 +1,643 @@ +{ + "HERO_SLIDE_1_TITLE": "", + "HERO_SLIDE_1": "", + "HERO_SLIDE_2_TITLE": "", + "HERO_SLIDE_2": "", + "HERO_SLIDE_3_TITLE": "", + "HERO_SLIDE_3": "", + "LOGIN": "", + "SIGN_UP": "", + "NEW_USER": "", + "EXISTING_USER": "", + "ENTER_NAME": "", + "PUBLIC_UPLOADER_NAME_MESSAGE": "", + "ENTER_EMAIL": "", + "EMAIL_ERROR": "", + "REQUIRED": "", + "EMAIL_SENT": "", + "CHECK_INBOX": "", + "ENTER_OTT": "", + "RESEND_MAIL": "", + "VERIFY": "", + "UNKNOWN_ERROR": "", + "INVALID_CODE": "", + "EXPIRED_CODE": "", + "SENDING": "", + "SENT": "", + "password": "", + "link_password_description": "", + "unlock": "", + "SET_PASSPHRASE": "", + "VERIFY_PASSPHRASE": "", + "INCORRECT_PASSPHRASE": "", + "ENTER_ENC_PASSPHRASE": "", + "PASSPHRASE_DISCLAIMER": "", + "WELCOME_TO_ENTE_HEADING": "", + "WELCOME_TO_ENTE_SUBHEADING": "", + "WHERE_YOUR_BEST_PHOTOS_LIVE": "", + "KEY_GENERATION_IN_PROGRESS_MESSAGE": "", + "PASSPHRASE_HINT": "", + "CONFIRM_PASSPHRASE": "", + "REFERRAL_CODE_HINT": "", + "REFERRAL_INFO": "", + "PASSPHRASE_MATCH_ERROR": "", + "CREATE_COLLECTION": "", + "ENTER_ALBUM_NAME": "", + "CLOSE_OPTION": "", + "ENTER_FILE_NAME": "", + "CLOSE": "", + "NO": "", + "NOTHING_HERE": "", + "UPLOAD": "", + "IMPORT": "", + "ADD_PHOTOS": "", + "ADD_MORE_PHOTOS": "", + "add_photos_one": "", + "add_photos_other": "", + "SELECT_PHOTOS": "", + "FILE_UPLOAD": "", + "UPLOAD_STAGE_MESSAGE": { + "0": "", + "1": "", + "2": "", + "3": "", + "4": "", + "5": "" + }, + "FILE_NOT_UPLOADED_LIST": "", + "SUBSCRIPTION_EXPIRED": "", + "SUBSCRIPTION_EXPIRED_MESSAGE": "", + "STORAGE_QUOTA_EXCEEDED": "", + "INITIAL_LOAD_DELAY_WARNING": "", + "USER_DOES_NOT_EXIST": "", + "NO_ACCOUNT": "", + "ACCOUNT_EXISTS": "", + "CREATE": "", + "DOWNLOAD": "", + "DOWNLOAD_OPTION": "", + "DOWNLOAD_FAVORITES": "", + "DOWNLOAD_UNCATEGORIZED": "", + "DOWNLOAD_HIDDEN_ITEMS": "", + "COPY_OPTION": "", + "TOGGLE_FULLSCREEN": "", + "ZOOM_IN_OUT": "", + "PREVIOUS": "", + "NEXT": "", + "title_photos": "", + "title_auth": "", + "title_accounts": "", + "UPLOAD_FIRST_PHOTO": "", + "IMPORT_YOUR_FOLDERS": "", + "UPLOAD_DROPZONE_MESSAGE": "", + "WATCH_FOLDER_DROPZONE_MESSAGE": "", + "TRASH_FILES_TITLE": "", + "TRASH_FILE_TITLE": "", + "DELETE_FILES_TITLE": "", + "DELETE_FILES_MESSAGE": "", + "DELETE": "", + "DELETE_OPTION": "", + "FAVORITE_OPTION": "", + "UNFAVORITE_OPTION": "", + "MULTI_FOLDER_UPLOAD": "", + "UPLOAD_STRATEGY_CHOICE": "", + "UPLOAD_STRATEGY_SINGLE_COLLECTION": "", + "OR": "", + "UPLOAD_STRATEGY_COLLECTION_PER_FOLDER": "", + "SESSION_EXPIRED_MESSAGE": "", + "SESSION_EXPIRED": "", + "PASSWORD_GENERATION_FAILED": "", + "CHANGE_PASSWORD": "", + "GO_BACK": "", + "RECOVERY_KEY": "", + "SAVE_LATER": "", + "SAVE": "", + "RECOVERY_KEY_DESCRIPTION": "", + "RECOVER_KEY_GENERATION_FAILED": "", + "KEY_NOT_STORED_DISCLAIMER": "", + "FORGOT_PASSWORD": "", + "RECOVER_ACCOUNT": "", + "RECOVERY_KEY_HINT": "", + "RECOVER": "", + "NO_RECOVERY_KEY": "", + "INCORRECT_RECOVERY_KEY": "", + "SORRY": "", + "NO_RECOVERY_KEY_MESSAGE": "", + "NO_TWO_FACTOR_RECOVERY_KEY_MESSAGE": "", + "CONTACT_SUPPORT": "", + "REQUEST_FEATURE": "", + "SUPPORT": "", + "CONFIRM": "", + "CANCEL": "", + "LOGOUT": "", + "DELETE_ACCOUNT": "", + "DELETE_ACCOUNT_MESSAGE": "", + "LOGOUT_MESSAGE": "", + "CHANGE_EMAIL": "", + "OK": "", + "SUCCESS": "", + "ERROR": "", + "MESSAGE": "", + "INSTALL_MOBILE_APP": "", + "DOWNLOAD_APP_MESSAGE": "", + "DOWNLOAD_APP": "", + "EXPORT": "", + "SUBSCRIPTION": "", + "SUBSCRIBE": "", + "MANAGEMENT_PORTAL": "", + "MANAGE_FAMILY_PORTAL": "", + "LEAVE_FAMILY_PLAN": "", + "LEAVE": "", + "LEAVE_FAMILY_CONFIRM": "", + "CHOOSE_PLAN": "", + "MANAGE_PLAN": "", + "ACTIVE": "", + "OFFLINE_MSG": "", + "FREE_SUBSCRIPTION_INFO": "", + "FAMILY_SUBSCRIPTION_INFO": "", + "RENEWAL_ACTIVE_SUBSCRIPTION_STATUS": "", + "RENEWAL_CANCELLED_SUBSCRIPTION_STATUS": "", + "RENEWAL_CANCELLED_SUBSCRIPTION_INFO": "", + "ADD_ON_AVAILABLE_TILL": "", + "STORAGE_QUOTA_EXCEEDED_SUBSCRIPTION_INFO": "", + "SUBSCRIPTION_PURCHASE_SUCCESS": "", + "SUBSCRIPTION_PURCHASE_CANCELLED": "", + "SUBSCRIPTION_PURCHASE_FAILED": "", + "SUBSCRIPTION_UPDATE_FAILED": "", + "UPDATE_PAYMENT_METHOD_MESSAGE": "", + "STRIPE_AUTHENTICATION_FAILED": "", + "UPDATE_PAYMENT_METHOD": "", + "MONTHLY": "", + "YEARLY": "", + "update_subscription_title": "", + "UPDATE_SUBSCRIPTION_MESSAGE": "", + "UPDATE_SUBSCRIPTION": "", + "CANCEL_SUBSCRIPTION": "", + "CANCEL_SUBSCRIPTION_MESSAGE": "", + "CANCEL_SUBSCRIPTION_WITH_ADDON_MESSAGE": "", + "SUBSCRIPTION_CANCEL_FAILED": "", + "SUBSCRIPTION_CANCEL_SUCCESS": "", + "REACTIVATE_SUBSCRIPTION": "", + "REACTIVATE_SUBSCRIPTION_MESSAGE": "", + "SUBSCRIPTION_ACTIVATE_SUCCESS": "", + "SUBSCRIPTION_ACTIVATE_FAILED": "", + "SUBSCRIPTION_PURCHASE_SUCCESS_TITLE": "", + "CANCEL_SUBSCRIPTION_ON_MOBILE": "", + "CANCEL_SUBSCRIPTION_ON_MOBILE_MESSAGE": "", + "MAIL_TO_MANAGE_SUBSCRIPTION": "", + "RENAME": "", + "RENAME_FILE": "", + "RENAME_COLLECTION": "", + "DELETE_COLLECTION_TITLE": "", + "DELETE_COLLECTION": "", + "DELETE_COLLECTION_MESSAGE": "", + "DELETE_PHOTOS": "", + "KEEP_PHOTOS": "", + "SHARE_COLLECTION": "", + "SHARE_WITH_SELF": "", + "ALREADY_SHARED": "", + "SHARING_BAD_REQUEST_ERROR": "", + "SHARING_DISABLED_FOR_FREE_ACCOUNTS": "", + "DOWNLOAD_COLLECTION": "", + "CREATE_ALBUM_FAILED": "", + "SEARCH": "", + "SEARCH_RESULTS": "", + "NO_RESULTS": "", + "SEARCH_HINT": "", + "SEARCH_TYPE": { + "COLLECTION": "", + "LOCATION": "", + "CITY": "", + "DATE": "", + "FILE_NAME": "", + "THING": "", + "FILE_CAPTION": "", + "FILE_TYPE": "", + "CLIP": "" + }, + "photos_count_zero": "", + "photos_count_one": "", + "photos_count_other": "", + "TERMS_AND_CONDITIONS": "", + "ADD_TO_COLLECTION": "", + "SELECTED": "", + "PEOPLE": "", + "INDEXING_SCHEDULED": "", + "ANALYZING_PHOTOS": "", + "INDEXING_PEOPLE": "", + "INDEXING_DONE": "", + "UNIDENTIFIED_FACES": "", + "OBJECTS": "", + "TEXT": "", + "INFO": "", + "INFO_OPTION": "", + "FILE_NAME": "", + "CAPTION_PLACEHOLDER": "", + "LOCATION": "", + "SHOW_ON_MAP": "", + "MAP": "", + "MAP_SETTINGS": "", + "ENABLE_MAPS": "", + "ENABLE_MAP": "", + "DISABLE_MAPS": "", + "ENABLE_MAP_DESCRIPTION": "", + "DISABLE_MAP_DESCRIPTION": "", + "DISABLE_MAP": "", + "DETAILS": "", + "VIEW_EXIF": "", + "NO_EXIF": "", + "EXIF": "", + "ISO": "", + "TWO_FACTOR": "", + "TWO_FACTOR_AUTHENTICATION": "", + "TWO_FACTOR_QR_INSTRUCTION": "", + "ENTER_CODE_MANUALLY": "", + "TWO_FACTOR_MANUAL_CODE_INSTRUCTION": "", + "SCAN_QR_CODE": "", + "ENABLE_TWO_FACTOR": "", + "ENABLE": "", + "LOST_DEVICE": "", + "INCORRECT_CODE": "", + "TWO_FACTOR_INFO": "", + "DISABLE_TWO_FACTOR_LABEL": "", + "UPDATE_TWO_FACTOR_LABEL": "", + "DISABLE": "", + "RECONFIGURE": "", + "UPDATE_TWO_FACTOR": "", + "UPDATE_TWO_FACTOR_MESSAGE": "", + "UPDATE": "", + "DISABLE_TWO_FACTOR": "", + "DISABLE_TWO_FACTOR_MESSAGE": "", + "TWO_FACTOR_DISABLE_FAILED": "", + "EXPORT_DATA": "", + "SELECT_FOLDER": "", + "DESTINATION": "", + "START": "", + "LAST_EXPORT_TIME": "", + "EXPORT_AGAIN": "", + "LOCAL_STORAGE_NOT_ACCESSIBLE": "", + "LOCAL_STORAGE_NOT_ACCESSIBLE_MESSAGE": "", + "SEND_OTT": "", + "EMAIl_ALREADY_OWNED": "", + "ETAGS_BLOCKED": "", + "LIVE_PHOTOS_DETECTED": "", + "RETRY_FAILED": "", + "FAILED_UPLOADS": "", + "SKIPPED_FILES": "", + "THUMBNAIL_GENERATION_FAILED_UPLOADS": "", + "UNSUPPORTED_FILES": "", + "SUCCESSFUL_UPLOADS": "", + "SKIPPED_INFO": "", + "UNSUPPORTED_INFO": "", + "BLOCKED_UPLOADS": "", + "INPROGRESS_METADATA_EXTRACTION": "", + "INPROGRESS_UPLOADS": "", + "TOO_LARGE_UPLOADS": "", + "LARGER_THAN_AVAILABLE_STORAGE_UPLOADS": "", + "LARGER_THAN_AVAILABLE_STORAGE_INFO": "", + "TOO_LARGE_INFO": "", + "THUMBNAIL_GENERATION_FAILED_INFO": "", + "UPLOAD_TO_COLLECTION": "", + "UNCATEGORIZED": "", + "ARCHIVE": "", + "FAVORITES": "", + "ARCHIVE_COLLECTION": "", + "ARCHIVE_SECTION_NAME": "", + "ALL_SECTION_NAME": "", + "MOVE_TO_COLLECTION": "", + "UNARCHIVE": "", + "UNARCHIVE_COLLECTION": "", + "HIDE_COLLECTION": "", + "UNHIDE_COLLECTION": "", + "MOVE": "", + "ADD": "", + "REMOVE": "", + "YES_REMOVE": "", + "REMOVE_FROM_COLLECTION": "", + "TRASH": "", + "MOVE_TO_TRASH": "", + "TRASH_FILES_MESSAGE": "", + "TRASH_FILE_MESSAGE": "", + "DELETE_PERMANENTLY": "", + "RESTORE": "", + "RESTORE_TO_COLLECTION": "", + "EMPTY_TRASH": "", + "EMPTY_TRASH_TITLE": "", + "EMPTY_TRASH_MESSAGE": "", + "LEAVE_SHARED_ALBUM": "", + "LEAVE_ALBUM": "", + "LEAVE_SHARED_ALBUM_TITLE": "", + "LEAVE_SHARED_ALBUM_MESSAGE": "", + "NOT_FILE_OWNER": "", + "CONFIRM_SELF_REMOVE_MESSAGE": "", + "CONFIRM_SELF_AND_OTHER_REMOVE_MESSAGE": "", + "SORT_BY_CREATION_TIME_ASCENDING": "", + "SORT_BY_UPDATION_TIME_DESCENDING": "", + "SORT_BY_NAME": "", + "FIX_CREATION_TIME": "", + "FIX_CREATION_TIME_IN_PROGRESS": "", + "CREATION_TIME_UPDATED": "", + "UPDATE_CREATION_TIME_NOT_STARTED": "", + "UPDATE_CREATION_TIME_COMPLETED": "", + "UPDATE_CREATION_TIME_COMPLETED_WITH_ERROR": "", + "CAPTION_CHARACTER_LIMIT": "", + "DATE_TIME_ORIGINAL": "", + "DATE_TIME_DIGITIZED": "", + "METADATA_DATE": "", + "CUSTOM_TIME": "", + "REOPEN_PLAN_SELECTOR_MODAL": "", + "OPEN_PLAN_SELECTOR_MODAL_FAILED": "", + "INSTALL": "", + "SHARING_DETAILS": "", + "MODIFY_SHARING": "", + "ADD_COLLABORATORS": "", + "ADD_NEW_EMAIL": "", + "shared_with_people_zero": "", + "shared_with_people_one": "", + "shared_with_people_other": "", + "participants_zero": "", + "participants_one": "", + "participants_other": "", + "ADD_VIEWERS": "", + "CHANGE_PERMISSIONS_TO_VIEWER": "", + "CHANGE_PERMISSIONS_TO_COLLABORATOR": "", + "CONVERT_TO_VIEWER": "", + "CONVERT_TO_COLLABORATOR": "", + "CHANGE_PERMISSION": "", + "REMOVE_PARTICIPANT": "", + "CONFIRM_REMOVE": "", + "MANAGE": "", + "ADDED_AS": "", + "COLLABORATOR_RIGHTS": "", + "REMOVE_PARTICIPANT_HEAD": "", + "OWNER": "", + "COLLABORATORS": "", + "ADD_MORE": "", + "VIEWERS": "", + "OR_ADD_EXISTING": "", + "REMOVE_PARTICIPANT_MESSAGE": "", + "NOT_FOUND": "", + "LINK_EXPIRED": "", + "LINK_EXPIRED_MESSAGE": "", + "MANAGE_LINK": "", + "LINK_TOO_MANY_REQUESTS": "", + "FILE_DOWNLOAD": "", + "link_password_lock": "", + "PUBLIC_COLLECT": "", + "LINK_DEVICE_LIMIT": "", + "NO_DEVICE_LIMIT": "", + "LINK_EXPIRY": "", + "NEVER": "", + "DISABLE_FILE_DOWNLOAD": "", + "DISABLE_FILE_DOWNLOAD_MESSAGE": "", + "SHARED_USING": "", + "SHARING_REFERRAL_CODE": "", + "LIVE": "", + "DISABLE_PASSWORD": "", + "DISABLE_PASSWORD_MESSAGE": "", + "PASSWORD_LOCK": "", + "LOCK": "", + "DOWNLOAD_UPLOAD_LOGS": "", + "UPLOAD_FILES": "", + "UPLOAD_DIRS": "", + "UPLOAD_GOOGLE_TAKEOUT": "", + "DEDUPLICATE_FILES": "", + "NO_DUPLICATES_FOUND": "", + "FILES": "", + "EACH": "", + "DEDUPLICATE_BASED_ON_SIZE": "", + "STOP_ALL_UPLOADS_MESSAGE": "", + "STOP_UPLOADS_HEADER": "", + "YES_STOP_UPLOADS": "", + "STOP_DOWNLOADS_HEADER": "", + "YES_STOP_DOWNLOADS": "", + "STOP_ALL_DOWNLOADS_MESSAGE": "", + "albums_one": "", + "albums_other": "", + "ALL_ALBUMS": "", + "ALBUMS": "", + "ALL_HIDDEN_ALBUMS": "", + "HIDDEN_ALBUMS": "", + "HIDDEN_ITEMS": "", + "ENTER_TWO_FACTOR_OTP": "", + "CREATE_ACCOUNT": "", + "COPIED": "", + "WATCH_FOLDERS": "", + "UPGRADE_NOW": "", + "RENEW_NOW": "", + "STORAGE": "", + "USED": "", + "YOU": "", + "FAMILY": "", + "FREE": "", + "OF": "", + "WATCHED_FOLDERS": "", + "NO_FOLDERS_ADDED": "", + "FOLDERS_AUTOMATICALLY_MONITORED": "", + "UPLOAD_NEW_FILES_TO_ENTE": "", + "REMOVE_DELETED_FILES_FROM_ENTE": "", + "ADD_FOLDER": "", + "STOP_WATCHING": "", + "STOP_WATCHING_FOLDER": "", + "STOP_WATCHING_DIALOG_MESSAGE": "", + "YES_STOP": "", + "MONTH_SHORT": "", + "YEAR": "", + "FAMILY_PLAN": "", + "DOWNLOAD_LOGS": "", + "DOWNLOAD_LOGS_MESSAGE": "", + "CHANGE_FOLDER": "", + "TWO_MONTHS_FREE": "", + "POPULAR": "", + "FREE_PLAN_OPTION_LABEL": "", + "free_plan_description": "", + "CURRENT_USAGE": "", + "WEAK_DEVICE": "", + "DRAG_AND_DROP_HINT": "", + "CONFIRM_ACCOUNT_DELETION_MESSAGE": "", + "AUTHENTICATE": "", + "UPLOADED_TO_SINGLE_COLLECTION": "", + "UPLOADED_TO_SEPARATE_COLLECTIONS": "", + "NEVERMIND": "", + "UPDATE_AVAILABLE": "", + "UPDATE_INSTALLABLE_MESSAGE": "", + "INSTALL_NOW": "", + "INSTALL_ON_NEXT_LAUNCH": "", + "UPDATE_AVAILABLE_MESSAGE": "", + "DOWNLOAD_AND_INSTALL": "", + "IGNORE_THIS_VERSION": "", + "TODAY": "", + "YESTERDAY": "", + "NAME_PLACEHOLDER": "", + "ROOT_LEVEL_FILE_WITH_FOLDER_NOT_ALLOWED": "", + "ROOT_LEVEL_FILE_WITH_FOLDER_NOT_ALLOWED_MESSAGE": "", + "CHOSE_THEME": "", + "ML_SEARCH": "", + "ENABLE_ML_SEARCH_DESCRIPTION": "", + "ML_MORE_DETAILS": "", + "ENABLE_FACE_SEARCH": "", + "ENABLE_FACE_SEARCH_TITLE": "", + "ENABLE_FACE_SEARCH_DESCRIPTION": "", + "DISABLE_BETA": "", + "DISABLE_FACE_SEARCH": "", + "DISABLE_FACE_SEARCH_TITLE": "", + "DISABLE_FACE_SEARCH_DESCRIPTION": "", + "ADVANCED": "", + "FACE_SEARCH_CONFIRMATION": "", + "LABS": "", + "YOURS": "", + "PASSPHRASE_STRENGTH_WEAK": "", + "PASSPHRASE_STRENGTH_MODERATE": "", + "PASSPHRASE_STRENGTH_STRONG": "", + "PREFERENCES": "", + "LANGUAGE": "", + "EXPORT_DIRECTORY_DOES_NOT_EXIST": "", + "EXPORT_DIRECTORY_DOES_NOT_EXIST_MESSAGE": "", + "SUBSCRIPTION_VERIFICATION_ERROR": "", + "storage_unit": { + "b": "", + "kb": "", + "mb": "", + "gb": "", + "tb": "" + }, + "AFTER_TIME": { + "HOUR": "", + "DAY": "", + "WEEK": "", + "MONTH": "", + "YEAR": "" + }, + "COPY_LINK": "", + "DONE": "", + "LINK_SHARE_TITLE": "", + "REMOVE_LINK": "", + "CREATE_PUBLIC_SHARING": "", + "PUBLIC_LINK_CREATED": "", + "PUBLIC_LINK_ENABLED": "", + "COLLECT_PHOTOS": "", + "PUBLIC_COLLECT_SUBTEXT": "", + "STOP_EXPORT": "", + "EXPORT_PROGRESS": "", + "MIGRATING_EXPORT": "", + "RENAMING_COLLECTION_FOLDERS": "", + "TRASHING_DELETED_FILES": "", + "TRASHING_DELETED_COLLECTIONS": "", + "CONTINUOUS_EXPORT": "", + "PENDING_ITEMS": "", + "EXPORT_STARTING": "", + "DELETE_ACCOUNT_REASON_LABEL": "", + "DELETE_ACCOUNT_REASON_PLACEHOLDER": "", + "DELETE_REASON": { + "MISSING_FEATURE": "", + "BROKEN_BEHAVIOR": "", + "FOUND_ANOTHER_SERVICE": "", + "NOT_LISTED": "" + }, + "DELETE_ACCOUNT_FEEDBACK_LABEL": "", + "DELETE_ACCOUNT_FEEDBACK_PLACEHOLDER": "", + "CONFIRM_DELETE_ACCOUNT_CHECKBOX_LABEL": "", + "CONFIRM_DELETE_ACCOUNT": "", + "FEEDBACK_REQUIRED": "", + "FEEDBACK_REQUIRED_FOUND_ANOTHER_SERVICE": "", + "RECOVER_TWO_FACTOR": "", + "at": "", + "AUTH_NEXT": "", + "AUTH_DOWNLOAD_MOBILE_APP": "", + "HIDDEN": "", + "HIDE": "", + "UNHIDE": "", + "UNHIDE_TO_COLLECTION": "", + "SORT_BY": "", + "NEWEST_FIRST": "", + "OLDEST_FIRST": "", + "CONVERSION_FAILED_NOTIFICATION_MESSAGE": "", + "SELECT_COLLECTION": "", + "PIN_ALBUM": "", + "UNPIN_ALBUM": "", + "DOWNLOAD_COMPLETE": "", + "DOWNLOADING_COLLECTION": "", + "DOWNLOAD_FAILED": "", + "DOWNLOAD_PROGRESS": "", + "CHRISTMAS": "", + "CHRISTMAS_EVE": "", + "NEW_YEAR": "", + "NEW_YEAR_EVE": "", + "IMAGE": "", + "VIDEO": "", + "LIVE_PHOTO": "", + "editor": { + "crop": "" + }, + "CONVERT": "", + "CONFIRM_EDITOR_CLOSE_MESSAGE": "", + "CONFIRM_EDITOR_CLOSE_DESCRIPTION": "", + "BRIGHTNESS": "", + "CONTRAST": "", + "SATURATION": "", + "BLUR": "", + "INVERT_COLORS": "", + "ASPECT_RATIO": "", + "SQUARE": "", + "ROTATE_LEFT": "", + "ROTATE_RIGHT": "", + "FLIP_VERTICALLY": "", + "FLIP_HORIZONTALLY": "", + "DOWNLOAD_EDITED": "", + "SAVE_A_COPY_TO_ENTE": "", + "RESTORE_ORIGINAL": "", + "TRANSFORM": "", + "COLORS": "", + "FLIP": "", + "ROTATION": "", + "RESET": "", + "PHOTO_EDITOR": "", + "FASTER_UPLOAD": "", + "FASTER_UPLOAD_DESCRIPTION": "", + "MAGIC_SEARCH_STATUS": "", + "INDEXED_ITEMS": "", + "CAST_ALBUM_TO_TV": "", + "ENTER_CAST_PIN_CODE": "", + "PAIR_DEVICE_TO_TV": "", + "TV_NOT_FOUND": "", + "AUTO_CAST_PAIR": "", + "AUTO_CAST_PAIR_DESC": "", + "PAIR_WITH_PIN": "", + "CHOOSE_DEVICE_FROM_BROWSER": "", + "PAIR_WITH_PIN_DESC": "", + "VISIT_CAST_ENTE_IO": "", + "CAST_AUTO_PAIR_FAILED": "", + "FREEHAND": "", + "APPLY_CROP": "", + "PHOTO_EDIT_REQUIRED_TO_SAVE": "", + "passkeys": "", + "passkey_fetch_failed": "", + "manage_passkey": "", + "delete_passkey": "", + "delete_passkey_confirmation": "", + "rename_passkey": "", + "add_passkey": "", + "enter_passkey_name": "", + "passkeys_description": "", + "CREATED_AT": "", + "passkey_add_failed": "", + "passkey_login_failed": "", + "passkey_login_invalid_url": "", + "passkey_login_already_claimed_session": "", + "passkey_login_generic_error": "", + "passkey_login_credential_hint": "", + "passkeys_not_supported": "", + "try_again": "", + "check_status": "", + "passkey_login_instructions": "", + "passkey_login": "", + "passkey": "", + "passkey_verify_description": "", + "waiting_for_verification": "", + "verification_still_pending": "", + "passkey_verified": "", + "redirecting_back_to_app": "", + "redirect_close_instructions": "", + "redirect_again": "", + "autogenerated_first_album_name": "", + "autogenerated_default_album_name": "" +} diff --git a/web/packages/next/locales/th-TH/translation.json b/web/packages/next/locales/th-TH/translation.json index 0750f1fbfc..8c268c2c86 100644 --- a/web/packages/next/locales/th-TH/translation.json +++ b/web/packages/next/locales/th-TH/translation.json @@ -24,9 +24,9 @@ "EXPIRED_CODE": "", "SENDING": "", "SENT": "", - "PASSWORD": "", - "LINK_PASSWORD": "", - "RETURN_PASSPHRASE_HINT": "", + "password": "", + "link_password_description": "", + "unlock": "", "SET_PASSPHRASE": "", "VERIFY_PASSPHRASE": "", "INCORRECT_PASSPHRASE": "", @@ -85,6 +85,7 @@ "NEXT": "", "title_photos": "", "title_auth": "", + "title_accounts": "", "UPLOAD_FIRST_PHOTO": "", "IMPORT_YOUR_FOLDERS": "", "UPLOAD_DROPZONE_MESSAGE": "", @@ -380,7 +381,7 @@ "MANAGE_LINK": "", "LINK_TOO_MANY_REQUESTS": "", "FILE_DOWNLOAD": "", - "LINK_PASSWORD_LOCK": "", + "link_password_lock": "", "PUBLIC_COLLECT": "", "LINK_DEVICE_LIMIT": "", "NO_DEVICE_LIMIT": "", @@ -608,20 +609,35 @@ "FREEHAND": "", "APPLY_CROP": "", "PHOTO_EDIT_REQUIRED_TO_SAVE": "", - "PASSKEYS": "", - "DELETE_PASSKEY": "", - "DELETE_PASSKEY_CONFIRMATION": "", - "RENAME_PASSKEY": "", - "ADD_PASSKEY": "", - "ENTER_PASSKEY_NAME": "", - "PASSKEYS_DESCRIPTION": "", + "passkeys": "", + "passkey_fetch_failed": "", + "manage_passkey": "", + "delete_passkey": "", + "delete_passkey_confirmation": "", + "rename_passkey": "", + "add_passkey": "", + "enter_passkey_name": "", + "passkeys_description": "", "CREATED_AT": "", - "PASSKEY_LOGIN_FAILED": "", - "PASSKEY_LOGIN_URL_INVALID": "", - "PASSKEY_LOGIN_ERRORED": "", - "TRY_AGAIN": "", - "PASSKEY_FOLLOW_THE_STEPS_FROM_YOUR_BROWSER": "", - "LOGIN_WITH_PASSKEY": "", + "passkey_add_failed": "", + "passkey_login_failed": "", + "passkey_login_invalid_url": "", + "passkey_login_already_claimed_session": "", + "passkey_login_generic_error": "", + "passkey_login_credential_hint": "", + "passkeys_not_supported": "", + "try_again": "", + "check_status": "", + "passkey_login_instructions": "", + "passkey_login": "", + "passkey": "", + "passkey_verify_description": "", + "waiting_for_verification": "", + "verification_still_pending": "", + "passkey_verified": "", + "redirecting_back_to_app": "", + "redirect_close_instructions": "", + "redirect_again": "", "autogenerated_first_album_name": "", "autogenerated_default_album_name": "" } diff --git a/web/packages/next/locales/tr-TR/translation.json b/web/packages/next/locales/tr-TR/translation.json index 0750f1fbfc..8c268c2c86 100644 --- a/web/packages/next/locales/tr-TR/translation.json +++ b/web/packages/next/locales/tr-TR/translation.json @@ -24,9 +24,9 @@ "EXPIRED_CODE": "", "SENDING": "", "SENT": "", - "PASSWORD": "", - "LINK_PASSWORD": "", - "RETURN_PASSPHRASE_HINT": "", + "password": "", + "link_password_description": "", + "unlock": "", "SET_PASSPHRASE": "", "VERIFY_PASSPHRASE": "", "INCORRECT_PASSPHRASE": "", @@ -85,6 +85,7 @@ "NEXT": "", "title_photos": "", "title_auth": "", + "title_accounts": "", "UPLOAD_FIRST_PHOTO": "", "IMPORT_YOUR_FOLDERS": "", "UPLOAD_DROPZONE_MESSAGE": "", @@ -380,7 +381,7 @@ "MANAGE_LINK": "", "LINK_TOO_MANY_REQUESTS": "", "FILE_DOWNLOAD": "", - "LINK_PASSWORD_LOCK": "", + "link_password_lock": "", "PUBLIC_COLLECT": "", "LINK_DEVICE_LIMIT": "", "NO_DEVICE_LIMIT": "", @@ -608,20 +609,35 @@ "FREEHAND": "", "APPLY_CROP": "", "PHOTO_EDIT_REQUIRED_TO_SAVE": "", - "PASSKEYS": "", - "DELETE_PASSKEY": "", - "DELETE_PASSKEY_CONFIRMATION": "", - "RENAME_PASSKEY": "", - "ADD_PASSKEY": "", - "ENTER_PASSKEY_NAME": "", - "PASSKEYS_DESCRIPTION": "", + "passkeys": "", + "passkey_fetch_failed": "", + "manage_passkey": "", + "delete_passkey": "", + "delete_passkey_confirmation": "", + "rename_passkey": "", + "add_passkey": "", + "enter_passkey_name": "", + "passkeys_description": "", "CREATED_AT": "", - "PASSKEY_LOGIN_FAILED": "", - "PASSKEY_LOGIN_URL_INVALID": "", - "PASSKEY_LOGIN_ERRORED": "", - "TRY_AGAIN": "", - "PASSKEY_FOLLOW_THE_STEPS_FROM_YOUR_BROWSER": "", - "LOGIN_WITH_PASSKEY": "", + "passkey_add_failed": "", + "passkey_login_failed": "", + "passkey_login_invalid_url": "", + "passkey_login_already_claimed_session": "", + "passkey_login_generic_error": "", + "passkey_login_credential_hint": "", + "passkeys_not_supported": "", + "try_again": "", + "check_status": "", + "passkey_login_instructions": "", + "passkey_login": "", + "passkey": "", + "passkey_verify_description": "", + "waiting_for_verification": "", + "verification_still_pending": "", + "passkey_verified": "", + "redirecting_back_to_app": "", + "redirect_close_instructions": "", + "redirect_again": "", "autogenerated_first_album_name": "", "autogenerated_default_album_name": "" } diff --git a/web/packages/next/locales/zh-CN/translation.json b/web/packages/next/locales/zh-CN/translation.json index 73be9e8f19..04375de14d 100644 --- a/web/packages/next/locales/zh-CN/translation.json +++ b/web/packages/next/locales/zh-CN/translation.json @@ -24,9 +24,9 @@ "EXPIRED_CODE": "您的验证码已过期", "SENDING": "发送中……", "SENT": "已发送!", - "PASSWORD": "密码", - "LINK_PASSWORD": "输入密码来解锁相册", - "RETURN_PASSPHRASE_HINT": "密码", + "password": "密码", + "link_password_description": "输入密码来解锁相册", + "unlock": "解锁", "SET_PASSPHRASE": "设置密码", "VERIFY_PASSPHRASE": "登录", "INCORRECT_PASSPHRASE": "密码错误", @@ -85,6 +85,7 @@ "NEXT": "下一个 (→)", "title_photos": "Ente 照片", "title_auth": "Ente 验证器", + "title_accounts": "Ente 账户", "UPLOAD_FIRST_PHOTO": "上传您的第一张照片", "IMPORT_YOUR_FOLDERS": "导入您的文件夹", "UPLOAD_DROPZONE_MESSAGE": "拖放以备份您的文件", @@ -380,7 +381,7 @@ "MANAGE_LINK": "管理链接", "LINK_TOO_MANY_REQUESTS": "抱歉,该相册已在太多设备上查看!", "FILE_DOWNLOAD": "允许下载", - "LINK_PASSWORD_LOCK": "密码锁", + "link_password_lock": "密码锁", "PUBLIC_COLLECT": "允许添加照片", "LINK_DEVICE_LIMIT": "设备限制", "NO_DEVICE_LIMIT": "无", @@ -608,20 +609,35 @@ "FREEHAND": "手画", "APPLY_CROP": "应用裁剪", "PHOTO_EDIT_REQUIRED_TO_SAVE": "保存之前必须至少执行一项转换或颜色调整。", - "PASSKEYS": "通行密钥", - "DELETE_PASSKEY": "删除通行密钥", - "DELETE_PASSKEY_CONFIRMATION": "您确定要删除此通行密钥吗?此操作是不可逆的。", - "RENAME_PASSKEY": "重命名通行密钥", - "ADD_PASSKEY": "添加通行密钥", - "ENTER_PASSKEY_NAME": "输入该通行密钥的名称", - "PASSKEYS_DESCRIPTION": "通行密钥是您 Ente 账户的现代、安全的第二因素。通行密钥使用设备上的生物识别认证,这既方便又安全。", + "passkeys": "通行密钥", + "passkey_fetch_failed": "无法获取您的通行密钥。", + "manage_passkey": "管理通行密钥", + "delete_passkey": "删除通行密钥", + "delete_passkey_confirmation": "您确定要删除此通行密钥吗?此操作是不可逆的。", + "rename_passkey": "重命名通行密钥", + "add_passkey": "添加通行密钥", + "enter_passkey_name": "输入该通行密钥的名称", + "passkeys_description": "通行密钥是您 Ente 账户的现代、安全的第二因素。通行密钥使用设备上的生物识别认证,这既方便又安全。", "CREATED_AT": "创建于", - "PASSKEY_LOGIN_FAILED": "通行密钥登录失败", - "PASSKEY_LOGIN_URL_INVALID": "该登录 URL 无效", - "PASSKEY_LOGIN_ERRORED": "使用通行密钥登录时出错。", - "TRY_AGAIN": "重试", - "PASSKEY_FOLLOW_THE_STEPS_FROM_YOUR_BROWSER": "按照浏览器中提示的步骤继续登录。", - "LOGIN_WITH_PASSKEY": "使用通行密钥来登录", + "passkey_add_failed": "无法添加通行密钥", + "passkey_login_failed": "通行密钥登录失败", + "passkey_login_invalid_url": "该登录 URL 无效", + "passkey_login_already_claimed_session": "", + "passkey_login_generic_error": "使用通行密钥登录时出错。", + "passkey_login_credential_hint": "", + "passkeys_not_supported": "此浏览器不支持通行密钥", + "try_again": "重试", + "check_status": "", + "passkey_login_instructions": "按照浏览器中提示的步骤继续登录。", + "passkey_login": "使用通行密钥来登录", + "passkey": "通行密钥", + "passkey_verify_description": "", + "waiting_for_verification": "正在等待验证...", + "verification_still_pending": "", + "passkey_verified": "已验证通行密钥", + "redirecting_back_to_app": "正在重定向至您的应用...", + "redirect_close_instructions": "在应用程序打开后您可以关闭此窗口。", + "redirect_again": "再次重定向", "autogenerated_first_album_name": "我的第一个相册", "autogenerated_default_album_name": "新建相册" } diff --git a/web/packages/next/log-web.ts b/web/packages/next/log-web.ts index f319118ce6..5b809875c1 100644 --- a/web/packages/next/log-web.ts +++ b/web/packages/next/log-web.ts @@ -1,21 +1,19 @@ import { isDevBuild } from "@/next/env"; import log from "@/next/log"; +import type { AppName } from "./types/app"; /** * Log a standard startup banner. * * This helps us identify app starts and other environment details in the logs. * - * @param appId An identifier of the app that is starting. + * @param appName The {@link AppName} of the app that is starting. * @param userId The uid for the currently logged in user, if any. */ -export const logStartupBanner = (appId: string, userId?: number) => { - // TODO (MR): Remove the need to lowercase it, change the enum itself. - const appIdL = appId.toLowerCase(); +export const logStartupBanner = (appName: AppName, userId?: number) => { const sha = process.env.GIT_SHA; const buildId = isDevBuild ? "dev " : sha ? `git ${sha} ` : ""; - - log.info(`Starting ente-${appIdL}-web ${buildId}uid ${userId ?? 0}`); + log.info(`Starting ente-${appName}-web ${buildId}uid ${userId ?? 0}`); }; /** diff --git a/web/packages/next/types/app.ts b/web/packages/next/types/app.ts index 5175cb26c3..6ca14398ad 100644 --- a/web/packages/next/types/app.ts +++ b/web/packages/next/types/app.ts @@ -4,7 +4,45 @@ import type { DialogBoxAttributesV2 } from "@ente/shared/components/DialogBoxV2/ * Arbitrary names that we used as keys for indexing various constants * corresponding to our apps that rely on this package. */ -export type AppName = "account" | "albums" | "auth" | "photos"; +export type AppName = "accounts" | "auth" | "photos"; + +/** + * Static title for the app. + * + * This is shown until we have the localized version. + */ +export const appTitle: Record = { + accounts: "Ente Accounts", + auth: "Ente Auth", + photos: "Ente Photos", +}; + +/** + * Client "package names" for each of our apps. + * + * These are used as the identifier in the user agent strings that we send to + * our own servers. + * + * In cases where this code works for both a web and a desktop app for the same + * app (currently only photos), return the platform specific package name. + */ +export const clientPackageName = (appName: AppName): string => { + if (globalThis.electron) { + if (appName != "photos") + throw new Error(`Unsupported desktop appName ${appName}`); + return clientPackageNamePhotosDesktop; + } + return _clientPackageName[appName]; +}; + +export const _clientPackageName: Record = { + accounts: "io.ente.accounts.web", + auth: "io.ente.auth.web", + photos: "io.ente.photos.web", +}; + +/** Client package name for the Photos desktop app */ +export const clientPackageNamePhotosDesktop = "io.ente.photos.desktop"; /** * Properties guaranteed to be present in the AppContext types for apps that are diff --git a/web/packages/next/types/credentials.ts b/web/packages/next/types/credentials.ts new file mode 100644 index 0000000000..483377d301 --- /dev/null +++ b/web/packages/next/types/credentials.ts @@ -0,0 +1,22 @@ +import { nullToUndefined } from "@/utils/transform"; +import { z } from "zod"; + +// TODO: Provide types +export const KeyAttributes = z.object({}).passthrough(); + +/** + * The result of a successful two factor verification (totp or passkey). + */ +export const TwoFactorAuthorizationResponse = z.object({ + id: z.number(), + /** TODO: keyAttributes is guaranteed to be returned by museum, update the + * types to reflect that. */ + keyAttributes: KeyAttributes.nullish().transform(nullToUndefined), + /** TODO: encryptedToken is guaranteed to be returned by museum, update the + * types to reflect that. */ + encryptedToken: z.string().nullish().transform(nullToUndefined), +}); + +export type TwoFactorAuthorizationResponse = z.infer< + typeof TwoFactorAuthorizationResponse +>; diff --git a/web/packages/next/types/ipc.ts b/web/packages/next/types/ipc.ts index 806a00cd5e..646ec79127 100644 --- a/web/packages/next/types/ipc.ts +++ b/web/packages/next/types/ipc.ts @@ -87,12 +87,29 @@ export interface Electron { * into the foreground. More precisely, the callback gets invoked when the * main window gets focus. * - * Note: Setting a callback clears any previous callbacks. + * Setting a callback clears any previous callbacks. * * @param cb The function to call when the main window gets focus. Pass * `undefined` to clear the callback. */ - onMainWindowFocus: (cb?: () => void) => void; + onMainWindowFocus: (cb: (() => void) | undefined) => void; + + /** + * Set or clear the callback {@link cb} to invoke whenever the app gets + * asked to open a deeplink. This allows the Node.js layer to ask the + * renderer to handle deeplinks and redirect itself to a new location if + * needed. + * + * In particular, this is necessary for handling passkey authentication. + * See: [Note: Passkey verification in the desktop app] + * + * Setting a callback clears any previous callbacks. + * + * @param cb The function to call when the app gets asked to open a + * "ente://" URL. The URL string (a.k.a. "deeplink") we were asked to open + * is passed to the function verbatim. + */ + onOpenURL: (cb: ((url: string) => void) | undefined) => void; // - App update @@ -101,10 +118,10 @@ export interface Electron { * (actionable) app update is available. This allows the Node.js layer to * ask the renderer to show an "Update available" dialog to the user. * - * Note: Setting a callback clears any previous callbacks. + * Setting a callback clears any previous callbacks. */ onAppUpdateAvailable: ( - cb?: ((update: AppUpdate) => void) | undefined, + cb: ((update: AppUpdate) => void) | undefined, ) => void; /** @@ -131,6 +148,23 @@ export interface Electron { */ skipAppUpdate: (version: string) => void; + /** + * Get the persisted version for the last shown changelog. + * + * See: [Note: Conditions for showing "What's new"] + */ + lastShownChangelogVersion: () => Promise; + + /** + * Save the given {@link version} to disk as the version of the last shown + * changelog. + * + * The value is saved to a store which is not cleared during logout. + * + * @see {@link lastShownChangelogVersion} + */ + setLastShownChangelogVersion: (version: number) => Promise; + // - FS /** diff --git a/web/packages/shared/apps/constants.ts b/web/packages/shared/apps/constants.ts deleted file mode 100644 index 37e62c93a3..0000000000 --- a/web/packages/shared/apps/constants.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { AppName } from "@/next/types/app"; -import { ACCOUNTS_PAGES, AUTH_PAGES, PHOTOS_PAGES } from "../constants/pages"; - -export enum APPS { - PHOTOS = "PHOTOS", - AUTH = "AUTH", - ALBUMS = "ALBUMS", - ACCOUNTS = "ACCOUNTS", -} - -export const appNameToAppNameOld = (appName: AppName): APPS => { - switch (appName) { - case "account": - return APPS.ACCOUNTS; - case "albums": - return APPS.ALBUMS; - case "photos": - return APPS.PHOTOS; - case "auth": - return APPS.AUTH; - } -}; - -export const CLIENT_PACKAGE_NAMES = new Map([ - [APPS.ALBUMS, "io.ente.albums.web"], - [APPS.PHOTOS, "io.ente.photos.web"], - [APPS.AUTH, "io.ente.auth.web"], - [APPS.ACCOUNTS, "io.ente.accounts.web"], -]); - -export const clientPackageNamePhotosDesktop = "io.ente.photos.desktop"; - -export const APP_TITLES = new Map([ - [APPS.ALBUMS, "Ente Albums"], - [APPS.PHOTOS, "Ente Photos"], - [APPS.AUTH, "Ente Auth"], - [APPS.ACCOUNTS, "Ente Accounts"], -]); - -export const APP_HOMES = new Map([ - [APPS.ALBUMS, "/"], - [APPS.PHOTOS, PHOTOS_PAGES.GALLERY], - [APPS.AUTH, AUTH_PAGES.AUTH], - [APPS.ACCOUNTS, ACCOUNTS_PAGES.PASSKEYS], -]); - -export const OTT_CLIENTS = new Map([ - [APPS.PHOTOS, "web"], - [APPS.AUTH, "totp"], -]); diff --git a/web/apps/photos/src/components/Directory/changeOption.tsx b/web/packages/shared/components/ChangeDirectoryOption.tsx similarity index 78% rename from web/apps/photos/src/components/Directory/changeOption.tsx rename to web/packages/shared/components/ChangeDirectoryOption.tsx index f846e9ba9d..72cc46af27 100644 --- a/web/apps/photos/src/components/Directory/changeOption.tsx +++ b/web/packages/shared/components/ChangeDirectoryOption.tsx @@ -4,9 +4,13 @@ import FolderIcon from "@mui/icons-material/Folder"; import MoreHoriz from "@mui/icons-material/MoreHoriz"; import { t } from "i18next"; +interface ChangeDirectoryOptionProps { + onClick: () => void; +} + export default function ChangeDirectoryOption({ - changeExportDirectory: changeDirectory, -}) { + onClick, +}: ChangeDirectoryOptionProps) { return ( } > - } - > + }> {t("CHANGE_FOLDER")} diff --git a/web/packages/shared/components/Collections/CollectionShare/publicShare/switch.tsx b/web/packages/shared/components/Collections/CollectionShare/publicShare/switch.tsx index bb738a9e9b..112791cb6e 100644 --- a/web/packages/shared/components/Collections/CollectionShare/publicShare/switch.tsx +++ b/web/packages/shared/components/Collections/CollectionShare/publicShare/switch.tsx @@ -1,4 +1,4 @@ -import { Switch, SwitchProps, styled } from "@mui/material"; +import { Switch, styled, type SwitchProps } from "@mui/material"; const PublicShareSwitch = styled((props: SwitchProps) => ( ({ + title: t("ERROR"), + close: { variant: "critical" }, + content: t("UNKNOWN_ERROR"), +}); diff --git a/web/packages/shared/components/LoginComponents.tsx b/web/packages/shared/components/LoginComponents.tsx new file mode 100644 index 0000000000..afe12a690d --- /dev/null +++ b/web/packages/shared/components/LoginComponents.tsx @@ -0,0 +1,221 @@ +import { isDevBuild } from "@/next/env"; +import log from "@/next/log"; +import type { BaseAppContextT } from "@/next/types/app"; +import { + checkPasskeyVerificationStatus, + passkeySessionExpiredErrorMessage, + saveCredentialsAndNavigateTo, +} from "@ente/accounts/services/passkey"; +import EnteButton from "@ente/shared/components/EnteButton"; +import { apiOrigin } from "@ente/shared/network/api"; +import { CircularProgress, Typography, styled } from "@mui/material"; +import { t } from "i18next"; +import { useRouter } from "next/router"; +import React, { useState } from "react"; +import { VerticallyCentered } from "./Container"; +import type { DialogBoxAttributesV2 } from "./DialogBoxV2/types"; +import { genericErrorAttributes } from "./ErrorComponents"; +import FormPaper from "./Form/FormPaper"; +import FormPaperFooter from "./Form/FormPaper/Footer"; +import LinkButton from "./LinkButton"; + +export const PasswordHeader: React.FC = ({ + children, +}) => { + return ( + + {t("password")} + {children} + + ); +}; + +const PasskeyHeader: React.FC = ({ children }) => { + return ( + + {"Passkey"} + {children} + + ); +}; + +const Header_ = styled("div")` + margin-block-end: 4rem; + display: flex; + flex-direction: column; + gap: 8px; +`; + +export const ConnectionDetails: React.FC = () => { + const host = new URL(apiOrigin()).host; + + return ( + + + {host} + + + ); +}; + +const ConnectionDetails_ = styled("div")` + margin-block-start: 1rem; +`; + +interface VerifyingPasskeyProps { + /** ID of the current passkey verification session. */ + passkeySessionID: string; + /** The email of the user whose passkey we're verifying. */ + email: string | undefined; + /** Called when the user wants to redirect again. */ + onRetry: () => void; + /** + * The appContext. + * + * Needs to be explicitly passed since this component is used in a package + * where the pages are not wrapped in the provider. + */ + appContext: BaseAppContextT; +} + +export const VerifyingPasskey: React.FC = ({ + passkeySessionID, + email, + onRetry, + appContext, +}) => { + const { logout, setDialogBoxAttributesV2 } = appContext; + + type VerificationStatus = "waiting" | "checking" | "pending"; + const [verificationStatus, setVerificationStatus] = + useState("waiting"); + + const router = useRouter(); + + const handleRetry = () => { + setVerificationStatus("waiting"); + onRetry(); + }; + + const handleCheckStatus = async () => { + setVerificationStatus("checking"); + try { + const response = + await checkPasskeyVerificationStatus(passkeySessionID); + if (!response) setVerificationStatus("pending"); + else router.push(saveCredentialsAndNavigateTo(response)); + } catch (e) { + log.error("Passkey verification status check failed", e); + setDialogBoxAttributesV2( + e instanceof Error && + e.message == passkeySessionExpiredErrorMessage + ? sessionExpiredDialogAttributes(logout) + : genericErrorAttributes(), + ); + setVerificationStatus("waiting"); + } + }; + + const handleRecover = () => { + router.push("/passkeys/recover"); + }; + + return ( + + + {email ?? ""} + + + + {verificationStatus == "checking" ? ( + + + + ) : ( + + {verificationStatus == "waiting" + ? t("waiting_for_verification") + : t("verification_still_pending")} + + )} + + + + + {t("try_again")} + + + + {t("check_status")} + + + + + + + {t("RECOVER_ACCOUNT")} + + + {t("CHANGE_EMAIL")} + + + + {isDevBuild && } + + + ); +}; + +const VerifyingPasskeyMiddle = styled("div")` + display: flex; + flex-direction: column; + + padding-block: 1rem; + gap: 4rem; +`; + +const VerifyingPasskeyStatus = styled("div")` + text-align: center; + /* Size of the CircularProgress (+ some margin) so that there is no layout + shift when it is shown */ + min-height: 2em; +`; + +const ButtonStack = styled("div")` + display: flex; + flex-direction: column; + gap: 1rem; +`; + +/** + * {@link DialogBoxAttributesV2} for showing the error when the user's session + * has expired. + * + * It asks them to login again. There is one button, which allows them to + * logout. + * + * @param onLogin Called when the user presses the "Login" button on the error + * dialog. + */ +export const sessionExpiredDialogAttributes = ( + onLogin: () => void, +): DialogBoxAttributesV2 => ({ + title: t("SESSION_EXPIRED"), + content: t("SESSION_EXPIRED_MESSAGE"), + nonClosable: true, + proceed: { + text: t("LOGIN"), + action: onLogin, + variant: "accent", + }, +}); diff --git a/web/packages/shared/components/Menu/EnteMenuItem.tsx b/web/packages/shared/components/Menu/EnteMenuItem.tsx index b06ff0cbf1..042348482b 100644 --- a/web/packages/shared/components/Menu/EnteMenuItem.tsx +++ b/web/packages/shared/components/Menu/EnteMenuItem.tsx @@ -1,25 +1,36 @@ +import { CaptionedText } from "@ente/shared/components/CaptionedText"; +import ChangeDirectoryOption from "@ente/shared/components/ChangeDirectoryOption"; +import PublicShareSwitch from "@ente/shared/components/Collections/CollectionShare/publicShare/switch"; import { SpaceBetweenFlex, VerticallyCenteredFlex, } from "@ente/shared/components/Container"; import { Box, - ButtonProps, MenuItem, Typography, + type ButtonProps, type TypographyProps, } from "@mui/material"; import React from "react"; -import { CaptionedText } from "../CaptionedText"; -import PublicShareSwitch from "../Collections/CollectionShare/publicShare/switch"; interface Iprops { onClick: () => void; color?: ButtonProps["color"]; - variant?: "primary" | "captioned" | "toggle" | "secondary" | "mini"; + variant?: + | "primary" + | "captioned" + | "toggle" + | "secondary" + | "mini" + | "path"; fontWeight?: TypographyProps["fontWeight"]; startIcon?: React.ReactNode; endIcon?: React.ReactNode; + /** + * One of {@link label} or {@link labelComponent} must be specified. + * TODO: Try and reflect this is the type. + */ label?: string; subText?: string; subIcon?: React.ReactNode; @@ -42,19 +53,21 @@ export function EnteMenuItem({ disabled = false, }: Iprops) { const handleButtonClick = () => { - if (variant === "toggle") { + if (variant === "path" || variant === "toggle") { return; } onClick(); }; const handleIconClick = () => { - if (variant !== "toggle") { + if (variant !== "path" && variant !== "toggle") { return; } onClick(); }; + const labelOrDefault = label ?? ""; + return ( - variant !== "captioned" && theme.palette[color].main, - ...(variant !== "secondary" && - variant !== "mini" && { - backgroundColor: (theme) => theme.colors.fill.faint, - }), + variant !== "captioned" + ? theme.palette[color].main + : "inherit", + backgroundColor: (theme) => + variant !== "secondary" && variant !== "mini" + ? theme.colors.fill.faint + : "inherit", "&:hover": { backgroundColor: (theme) => theme.colors.fill.faintPressed, }, @@ -86,17 +101,17 @@ export function EnteMenuItem({ ) : variant === "captioned" ? ( ) : variant === "mini" ? ( - {label} + {labelOrDefault} ) : ( - {label} + {labelOrDefault} )}
    @@ -109,6 +124,9 @@ export function EnteMenuItem({ onClick={handleIconClick} /> )} + {variant === "path" && ( + + )} diff --git a/web/packages/shared/components/RecoveryKey/index.tsx b/web/packages/shared/components/RecoveryKey.tsx similarity index 93% rename from web/packages/shared/components/RecoveryKey/index.tsx rename to web/packages/shared/components/RecoveryKey.tsx index 5bd261960d..559dabffd2 100644 --- a/web/packages/shared/components/RecoveryKey/index.tsx +++ b/web/packages/shared/components/RecoveryKey.tsx @@ -4,16 +4,17 @@ import DialogTitleWithCloseButton from "@ente/shared/components/DialogBox/TitleW import { getRecoveryKey } from "@ente/shared/crypto/helpers"; import { downloadAsFile } from "@ente/shared/utils"; import { + Box, Button, Dialog, DialogActions, DialogContent, Typography, + styled, } from "@mui/material"; import * as bip39 from "bip39"; import { t } from "i18next"; import { useEffect, useState } from "react"; -import { DashedBorderWrapper } from "./styledComponents"; // mobile client library only supports english. bip39.setDefaultWordlist("english"); @@ -82,3 +83,8 @@ function RecoveryKey({ somethingWentWrong, isMobile, ...props }: Props) { ); } export default RecoveryKey; + +const DashedBorderWrapper = styled(Box)(({ theme }) => ({ + border: `1px dashed ${theme.palette.grey.A400}`, + borderRadius: theme.spacing(1), +})); diff --git a/web/packages/shared/components/RecoveryKey/styledComponents.tsx b/web/packages/shared/components/RecoveryKey/styledComponents.tsx deleted file mode 100644 index 944001ebeb..0000000000 --- a/web/packages/shared/components/RecoveryKey/styledComponents.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { Box, styled } from "@mui/material"; - -export const DashedBorderWrapper = styled(Box)(({ theme }) => ({ - border: `1px dashed ${theme.palette.grey.A400}`, - borderRadius: theme.spacing(1), -})); diff --git a/web/packages/shared/components/VerifyMasterPasswordForm.tsx b/web/packages/shared/components/VerifyMasterPasswordForm.tsx index eab20f5beb..b1ec1bbf44 100644 --- a/web/packages/shared/components/VerifyMasterPasswordForm.tsx +++ b/web/packages/shared/components/VerifyMasterPasswordForm.tsx @@ -20,6 +20,13 @@ export interface VerifyMasterPasswordFormProps { ) => void; buttonText: string; submitButtonProps?: ButtonProps; + /** + * A callback invoked when the form wants to get {@link KeyAttributes}. + * + * This function can throw an `CustomError.TWO_FACTOR_ENABLED` to signal to + * the form that some other form of second factor is enabled and the user + * has been redirected to a two factor verification page. + */ getKeyAttributes?: (kek: string) => Promise; srpAttributes?: SRPAttributes; } @@ -102,7 +109,7 @@ export default function VerifyMasterPasswordForm({ return ( { await sodium.ready; - const keyPair: sodium.KeyPair = sodium.crypto_box_keypair(); + const keyPair = sodium.crypto_box_keypair(); return { - privateKey: await toB64(keyPair.privateKey), publicKey: await toB64(keyPair.publicKey), + privateKey: await toB64(keyPair.privateKey), }; -} +}; export async function boxSealOpen( input: string, @@ -398,10 +402,61 @@ export async function toB64(input: Uint8Array) { return sodium.to_base64(input, sodium.base64_variants.ORIGINAL); } -export async function toURLSafeB64(input: Uint8Array) { +/** Convert a {@link Uint8Array} to a URL safe Base64 encoded string. */ +export const toB64URLSafe = async (input: Uint8Array) => { await sodium.ready; return sodium.to_base64(input, sodium.base64_variants.URLSAFE); -} +}; + +/** + * Convert a {@link Uint8Array} to a URL safe Base64 encoded string. + * + * This differs from {@link toB64URLSafe} in that it does not append any + * trailing padding character(s) "=" to make the resultant string's length be an + * integer multiple of 4. + * + * - In some contexts, for example when serializing WebAuthn binary for + * transmission over the network, this is the required / recommended + * approach. + * + * - In other cases, for example when trying to pass an arbitrary JSON string + * via a URL parameter, this is also convenient so that we do not have to + * deal with any ambiguity surrounding the "=" which is also the query + * parameter key value separator. + */ +export const toB64URLSafeNoPadding = async (input: Uint8Array) => { + await sodium.ready; + return sodium.to_base64(input, sodium.base64_variants.URLSAFE_NO_PADDING); +}; + +/** + * Convert a Base64 encoded string to a {@link Uint8Array}. + * + * This is the converse of {@link toB64URLSafeNoPadding}, and does not expect + * its input string's length to be a an integer multiple of 4. + */ +export const fromB64URLSafeNoPadding = async (input: string) => { + await sodium.ready; + return sodium.from_base64(input, sodium.base64_variants.URLSAFE_NO_PADDING); +}; + +/** + * Variant of {@link toB64URLSafeNoPadding} that works with {@link strings}. See also + * its sibling method {@link fromB64URLSafeNoPaddingString}. + */ +export const toB64URLSafeNoPaddingString = async (input: string) => { + await sodium.ready; + return toB64URLSafeNoPadding(sodium.from_string(input)); +}; + +/** + * Variant of {@link fromB64URLSafeNoPadding} that works with {@link strings}. See also + * its sibling method {@link toB64URLSafeNoPaddingString}. + */ +export const fromB64URLSafeNoPaddingString = async (input: string) => { + await sodium.ready; + return sodium.to_string(await fromB64URLSafeNoPadding(input)); +}; export async function fromUTF8(input: string) { await sodium.ready; diff --git a/web/packages/shared/error/index.ts b/web/packages/shared/error/index.ts index d226d62b62..3dea65554b 100644 --- a/web/packages/shared/error/index.ts +++ b/web/packages/shared/error/index.ts @@ -68,7 +68,6 @@ export const CustomError = { PROCESSING_FAILED: "processing failed", EXPORT_RECORD_JSON_PARSING_FAILED: "export record json parsing failed", TWO_FACTOR_ENABLED: "two factor enabled", - PASSKEYS_TWO_FACTOR_ENABLED: "passkeys two factor enabled", CLIENT_ERROR: "client error", ServerError: "server error", FILE_NOT_FOUND: "file not found", diff --git a/web/packages/shared/network/api.ts b/web/packages/shared/network/api.ts index 7ba49ec960..f708e29e04 100644 --- a/web/packages/shared/network/api.ts +++ b/web/packages/shared/network/api.ts @@ -2,67 +2,23 @@ * Return the origin (scheme, host, port triple) that should be used for making * API requests to museum. * - * This defaults to api.ente.io, Ente's own servers, but can be overridden when - * running locally by setting the `NEXT_PUBLIC_ENTE_ENDPOINT` environment - * variable. + * This defaults to "https://api.ente.io", Ente's own servers, but can be + * overridden when self hosting or developing by setting the + * `NEXT_PUBLIC_ENTE_ENDPOINT` environment variable. */ -export const apiOrigin = () => getEndpoint(); +export const apiOrigin = () => customAPIOrigin() ?? "https://api.ente.io"; -export const getEndpoint = () => { - const endpoint = process.env.NEXT_PUBLIC_ENTE_ENDPOINT; - if (endpoint) { - return endpoint; - } - return "https://api.ente.io"; -}; +/** + * Return the overridden API origin, if one is defined by setting the + * `NEXT_PUBLIC_ENTE_ENDPOINT` environment variable. + * + * Otherwise return undefined. + */ +export const customAPIOrigin = () => + process.env.NEXT_PUBLIC_ENTE_ENDPOINT ?? undefined; -export const getFileURL = (id: number) => { - const endpoint = process.env.NEXT_PUBLIC_ENTE_ENDPOINT; - if (endpoint) { - return `${endpoint}/files/download/${id}`; - } - return `https://files.ente.io/?fileID=${id}`; -}; - -export const getPublicCollectionFileURL = (id: number) => { - const endpoint = process.env.NEXT_PUBLIC_ENTE_ENDPOINT; - if (endpoint) { - return `${endpoint}/public-collection/files/download/${id}`; - } - return `https://public-albums.ente.io/download/?fileID=${id}`; -}; - -export const getCastFileURL = (id: number) => { - const endpoint = process.env.NEXT_PUBLIC_ENTE_ENDPOINT; - if (endpoint) { - return `${endpoint}/cast/files/download/${id}`; - } - return `https://cast-albums.ente.io/download/?fileID=${id}`; -}; - -export const getCastThumbnailURL = (id: number) => { - const endpoint = process.env.NEXT_PUBLIC_ENTE_ENDPOINT; - if (endpoint) { - return `${endpoint}/cast/files/preview/${id}`; - } - return `https://cast-albums.ente.io/preview/?fileID=${id}`; -}; - -export const getThumbnailURL = (id: number) => { - const endpoint = process.env.NEXT_PUBLIC_ENTE_ENDPOINT; - if (endpoint) { - return `${endpoint}/files/preview/${id}`; - } - return `https://thumbnails.ente.io/?fileID=${id}`; -}; - -export const getPublicCollectionThumbnailURL = (id: number) => { - const endpoint = process.env.NEXT_PUBLIC_ENTE_ENDPOINT; - if (endpoint) { - return `${endpoint}/public-collection/files/preview/${id}`; - } - return `https://public-albums.ente.io/preview/?fileID=${id}`; -}; +/** Deprecated, use {@link apiOrigin} instead. */ +export const getEndpoint = apiOrigin; export const getUploadEndpoint = () => { const endpoint = process.env.NEXT_PUBLIC_ENTE_ENDPOINT; @@ -72,13 +28,15 @@ export const getUploadEndpoint = () => { return `https://uploader.ente.io`; }; -export const getAccountsURL = () => { - const accountsURL = process.env.NEXT_PUBLIC_ENTE_ACCOUNTS_ENDPOINT; - if (accountsURL) { - return accountsURL; - } - return `https://accounts.ente.io`; -}; +/** + * Return the URL of the Ente Accounts app. + * + * Defaults to our production instance, "https://accounts.ente.io", but can be + * overridden by setting the `NEXT_PUBLIC_ENTE_ACCOUNTS_URL` environment + * variable. + */ +export const accountsAppURL = () => + process.env.NEXT_PUBLIC_ENTE_ACCOUNTS_URL ?? `https://accounts.ente.io`; export const getAlbumsURL = () => { const albumsURL = process.env.NEXT_PUBLIC_ENTE_ALBUMS_ENDPOINT; @@ -93,7 +51,7 @@ export const getAlbumsURL = () => { * family plans. */ export const getFamilyPortalURL = () => { - const familyURL = process.env.NEXT_PUBLIC_ENTE_FAMILY_ENDPOINT; + const familyURL = process.env.NEXT_PUBLIC_ENTE_FAMILY_URL; if (familyURL) { return familyURL; } @@ -104,7 +62,7 @@ export const getFamilyPortalURL = () => { * Return the URL for the host that handles payment related functionality. */ export const getPaymentsURL = () => { - const paymentsURL = process.env.NEXT_PUBLIC_ENTE_PAYMENTS_ENDPOINT; + const paymentsURL = process.env.NEXT_PUBLIC_ENTE_PAYMENTS_URL; if (paymentsURL) { return paymentsURL; } diff --git a/web/packages/shared/storage/localStorage/index.ts b/web/packages/shared/storage/localStorage/index.ts index 2c204ae3c6..beefbf37fe 100644 --- a/web/packages/shared/storage/localStorage/index.ts +++ b/web/packages/shared/storage/localStorage/index.ts @@ -26,7 +26,6 @@ export enum LS_KEYS { SRP_ATTRIBUTES = "srpAttributes", CF_PROXY_DISABLED = "cfProxyDisabled", REFERRAL_SOURCE = "referralSource", - CLIENT_PACKAGE = "clientPackage", } export const setData = (key: LS_KEYS, value: object) => diff --git a/web/packages/shared/themes/colors.ts b/web/packages/shared/themes/colors.ts new file mode 100644 index 0000000000..92c2ed8ef9 --- /dev/null +++ b/web/packages/shared/themes/colors.ts @@ -0,0 +1,247 @@ +import type { FixedColors, ThemeColorsOptions } from "@mui/material"; +import { THEME_COLOR } from "./constants"; + +export type ColorAccentType = "auth" | "photos"; + +export const getColors = ( + themeColor: THEME_COLOR, + accentType: ColorAccentType, +): ThemeColorsOptions => { + switch (themeColor) { + case THEME_COLOR.LIGHT: + return { ...fixedColors(accentType), ...lightThemeColors }; + default: + return { ...fixedColors(accentType), ...darkThemeColors }; + } +}; + +const fixedColors = ( + accentType: "auth" | "photos", +): Pick => { + switch (accentType) { + case "auth": + return { + ...commonFixedColors, + accent: authAccentColor, + }; + default: + return { + ...commonFixedColors, + accent: photosAccentColor, + }; + } +}; + +const commonFixedColors: Partial> = + { + accent: { + A700: "#00B33C", + A500: "#1DB954", + A400: "#26CB5F", + A300: "#01DE4D", + }, + warning: { + A500: "#FFC247", + }, + danger: { + A800: "#F53434", + A700: "#EA3F3F", + A500: "#FF6565", + A400: "#FF6F6F", + }, + blur: { + base: 96, + muted: 48, + faint: 24, + }, + + white: { base: "#fff", muted: "rgba(255, 255, 255, 0.48)" }, + black: { base: "#000", muted: "rgba(0, 0, 0, 0.65)" }, + }; + +const authAccentColor = { + A700: "rgb(164, 0, 182)", + A500: "rgb(150, 13, 214)", + A400: "rgb(122, 41, 193)", + A300: "rgb(152, 77, 244)", +}; + +const photosAccentColor = { + A700: "#00B33C", + A500: "#1DB954", + A400: "#26CB5F", + A300: "#01DE4D", +}; + +const lightThemeColors: Omit = { + background: { + base: "#fff", + elevated: "#fff", + elevated2: "rgba(153, 153, 153, 0.04)", + }, + backdrop: { + base: "rgba(255, 255, 255, 0.92)", + muted: "rgba(255, 255, 255, 0.75)", + faint: "rgba(255, 255, 255, 0.30)", + }, + text: { + base: "#000", + muted: "rgba(0, 0, 0, 0.60)", + faint: "rgba(0, 0, 0, 0.50)", + }, + fill: { + base: "#000", + muted: "rgba(0, 0, 0, 0.12)", + faint: "rgba(0, 0, 0, 0.04)", + basePressed: "rgba(0, 0, 0, 0.87))", + faintPressed: "rgba(0, 0, 0, 0.08)", + strong: "rgba(0, 0, 0, 0.24)", + }, + stroke: { + base: "#000", + muted: "rgba(0, 0, 0, 0.24)", + faint: "rgba(0, 0, 0, 0.12)", + fainter: "rgba(0, 0, 0, 0.06)", + }, + + shadows: { + float: [{ x: 0, y: 0, blur: 10, color: "rgba(0, 0, 0, 0.25)" }], + menu: [ + { + x: 0, + y: 0, + blur: 6, + color: "rgba(0, 0, 0, 0.16)", + }, + { + x: 0, + y: 3, + blur: 6, + color: "rgba(0, 0, 0, 0.12)", + }, + ], + button: [ + { + x: 0, + y: 4, + blur: 4, + color: "rgba(0, 0, 0, 0.25)", + }, + ], + }, + avatarColors: [ + "#76549A", + "#DF7861", + "#94B49F", + "#87A2FB", + "#C689C6", + "#937DC2", + "#325288", + "#85B4E0", + "#C1A3A3", + "#E1A059", + "#426165", + "#6B77B2", + "#957FEF", + "#DD9DE2", + "#82AB8B", + "#9BBBE8", + "#8FBEBE", + "#8AC3A1", + "#A8B0F2", + "#B0C695", + "#E99AAD", + "#D18484", + "#78B5A7", + ], +}; + +const darkThemeColors: Omit = { + background: { + base: "#000000", + elevated: "#1b1b1b", + elevated2: "#252525", + }, + backdrop: { + base: "rgba(0, 0, 0, 0.90)", + muted: "rgba(0, 0, 0, 0.65)", + faint: "rgba(0, 0, 0,0.20)", + }, + text: { + base: "#fff", + muted: "rgba(255, 255, 255, 0.70)", + faint: "rgba(255, 255, 255, 0.50)", + }, + fill: { + base: "#fff", + muted: "rgba(255, 255, 255, 0.16)", + faint: "rgba(255, 255, 255, 0.12)", + basePressed: "rgba(255, 255, 255, 0.90)", + faintPressed: "rgba(255, 255, 255, 0.06)", + strong: "rgba(255, 255, 255, 0.32)", + }, + stroke: { + base: "#ffffff", + muted: "rgba(255,255,255,0.24)", + faint: "rgba(255,255,255,0.16)", + fainter: "rgba(255,255,255,0.08)", + }, + + shadows: { + float: [ + { + x: 0, + y: 2, + blur: 12, + color: "rgba(0, 0, 0, 0.75)", + }, + ], + menu: [ + { + x: 0, + y: 0, + blur: 6, + color: "rgba(0, 0, 0, 0.50)", + }, + { + x: 0, + y: 3, + blur: 6, + color: "rgba(0, 0, 0, 0.25)", + }, + ], + button: [ + { + x: 0, + y: 4, + blur: 4, + color: "rgba(0, 0, 0, 0.75)", + }, + ], + }, + avatarColors: [ + "#76549A", + "#DF7861", + "#94B49F", + "#87A2FB", + "#C689C6", + "#937DC2", + "#325288", + "#85B4E0", + "#C1A3A3", + "#E1A059", + "#426165", + "#6B77B2", + "#957FEF", + "#DD9DE2", + "#82AB8B", + "#9BBBE8", + "#8FBEBE", + "#8AC3A1", + "#A8B0F2", + "#B0C695", + "#E99AAD", + "#D18484", + "#78B5A7", + ], +}; diff --git a/web/packages/shared/themes/colors/dark.ts b/web/packages/shared/themes/colors/dark.ts deleted file mode 100644 index 3550c3be17..0000000000 --- a/web/packages/shared/themes/colors/dark.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { FixedColors, ThemeColorsOptions } from "@mui/material"; - -const darkThemeColors: Omit = { - background: { - base: "#000000", - elevated: "#1b1b1b", - elevated2: "#252525", - }, - backdrop: { - base: "rgba(0, 0, 0, 0.90)", - muted: "rgba(0, 0, 0, 0.65)", - faint: "rgba(0, 0, 0,0.20)", - }, - text: { - base: "#fff", - muted: "rgba(255, 255, 255, 0.70)", - faint: "rgba(255, 255, 255, 0.50)", - }, - fill: { - base: "#fff", - muted: "rgba(255, 255, 255, 0.16)", - faint: "rgba(255, 255, 255, 0.12)", - basePressed: "rgba(255, 255, 255, 0.90)", - faintPressed: "rgba(255, 255, 255, 0.06)", - strong: "rgba(255, 255, 255, 0.32)", - }, - stroke: { - base: "#ffffff", - muted: "rgba(255,255,255,0.24)", - faint: "rgba(255,255,255,0.16)", - fainter: "rgba(255,255,255,0.08)", - }, - - shadows: { - float: [ - { - x: 0, - y: 2, - blur: 12, - color: "rgba(0, 0, 0, 0.75)", - }, - ], - menu: [ - { - x: 0, - y: 0, - blur: 6, - color: "rgba(0, 0, 0, 0.50)", - }, - { - x: 0, - y: 3, - blur: 6, - color: "rgba(0, 0, 0, 0.25)", - }, - ], - button: [ - { - x: 0, - y: 4, - blur: 4, - color: "rgba(0, 0, 0, 0.75)", - }, - ], - }, - avatarColors: [ - "#76549A", - "#DF7861", - "#94B49F", - "#87A2FB", - "#C689C6", - "#937DC2", - "#325288", - "#85B4E0", - "#C1A3A3", - "#E1A059", - "#426165", - "#6B77B2", - "#957FEF", - "#DD9DE2", - "#82AB8B", - "#9BBBE8", - "#8FBEBE", - "#8AC3A1", - "#A8B0F2", - "#B0C695", - "#E99AAD", - "#D18484", - "#78B5A7", - ], -}; - -export default darkThemeColors; diff --git a/web/packages/shared/themes/colors/fixed.ts b/web/packages/shared/themes/colors/fixed.ts deleted file mode 100644 index 0433821674..0000000000 --- a/web/packages/shared/themes/colors/fixed.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { APPS } from "@ente/shared/apps/constants"; -import type { FixedColors, ThemeColorsOptions } from "@mui/material"; - -export const getFixesColors = ( - appName: APPS, -): Pick => { - switch (appName) { - case APPS.AUTH: - return { - ...commonFixedColors, - accent: authAccentColor, - }; - default: - return { - ...commonFixedColors, - accent: photosAccentColor, - }; - } -}; - -const commonFixedColors: Partial> = - { - accent: { - A700: "#00B33C", - A500: "#1DB954", - A400: "#26CB5F", - A300: "#01DE4D", - }, - warning: { - A500: "#FFC247", - }, - danger: { - A800: "#F53434", - A700: "#EA3F3F", - A500: "#FF6565", - A400: "#FF6F6F", - }, - blur: { - base: 96, - muted: 48, - faint: 24, - }, - - white: { base: "#fff", muted: "rgba(255, 255, 255, 0.48)" }, - black: { base: "#000", muted: "rgba(0, 0, 0, 0.65)" }, - }; - -const authAccentColor = { - A700: "rgb(164, 0, 182)", - A500: "rgb(150, 13, 214)", - A400: "rgb(122, 41, 193)", - A300: "rgb(152, 77, 244)", -}; - -const photosAccentColor = { - A700: "#00B33C", - A500: "#1DB954", - A400: "#26CB5F", - A300: "#01DE4D", -}; diff --git a/web/packages/shared/themes/colors/index.ts b/web/packages/shared/themes/colors/index.ts deleted file mode 100644 index 1bc874ae12..0000000000 --- a/web/packages/shared/themes/colors/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { APPS } from "@ente/shared/apps/constants"; -import type { ThemeColorsOptions } from "@mui/material"; -import { THEME_COLOR } from "../constants"; -import darkThemeColors from "./dark"; -import { getFixesColors } from "./fixed"; -import lightThemeColors from "./light"; - -export const getColors = ( - themeColor: THEME_COLOR, - appName: APPS, -): ThemeColorsOptions => { - switch (themeColor) { - case THEME_COLOR.LIGHT: - return { ...getFixesColors(appName), ...lightThemeColors }; - default: - return { ...getFixesColors(appName), ...darkThemeColors }; - } -}; diff --git a/web/packages/shared/themes/colors/light.ts b/web/packages/shared/themes/colors/light.ts deleted file mode 100644 index a354ee61e8..0000000000 --- a/web/packages/shared/themes/colors/light.ts +++ /dev/null @@ -1,86 +0,0 @@ -import type { FixedColors, ThemeColorsOptions } from "@mui/material"; - -const lightThemeColors: Omit = { - background: { - base: "#fff", - elevated: "#fff", - elevated2: "rgba(153, 153, 153, 0.04)", - }, - backdrop: { - base: "rgba(255, 255, 255, 0.92)", - muted: "rgba(255, 255, 255, 0.75)", - faint: "rgba(255, 255, 255, 0.30)", - }, - text: { - base: "#000", - muted: "rgba(0, 0, 0, 0.60)", - faint: "rgba(0, 0, 0, 0.50)", - }, - fill: { - base: "#000", - muted: "rgba(0, 0, 0, 0.12)", - faint: "rgba(0, 0, 0, 0.04)", - basePressed: "rgba(0, 0, 0, 0.87))", - faintPressed: "rgba(0, 0, 0, 0.08)", - strong: "rgba(0, 0, 0, 0.24)", - }, - stroke: { - base: "#000", - muted: "rgba(0, 0, 0, 0.24)", - faint: "rgba(0, 0, 0, 0.12)", - fainter: "rgba(0, 0, 0, 0.06)", - }, - - shadows: { - float: [{ x: 0, y: 0, blur: 10, color: "rgba(0, 0, 0, 0.25)" }], - menu: [ - { - x: 0, - y: 0, - blur: 6, - color: "rgba(0, 0, 0, 0.16)", - }, - { - x: 0, - y: 3, - blur: 6, - color: "rgba(0, 0, 0, 0.12)", - }, - ], - button: [ - { - x: 0, - y: 4, - blur: 4, - color: "rgba(0, 0, 0, 0.25)", - }, - ], - }, - avatarColors: [ - "#76549A", - "#DF7861", - "#94B49F", - "#87A2FB", - "#C689C6", - "#937DC2", - "#325288", - "#85B4E0", - "#C1A3A3", - "#E1A059", - "#426165", - "#6B77B2", - "#957FEF", - "#DD9DE2", - "#82AB8B", - "#9BBBE8", - "#8FBEBE", - "#8AC3A1", - "#A8B0F2", - "#B0C695", - "#E99AAD", - "#D18484", - "#78B5A7", - ], -}; - -export default lightThemeColors; diff --git a/web/packages/shared/themes/index.ts b/web/packages/shared/themes/index.ts index bedaa706e0..380da0f82c 100644 --- a/web/packages/shared/themes/index.ts +++ b/web/packages/shared/themes/index.ts @@ -1,13 +1,15 @@ -import { APPS } from "@ente/shared/apps/constants"; import { createTheme } from "@mui/material"; -import { getColors } from "./colors"; +import { getColors, type ColorAccentType } from "./colors"; import { getComponents } from "./components"; import { THEME_COLOR } from "./constants"; import { getPallette } from "./palette"; import { typography } from "./typography"; -export const getTheme = (themeColor: THEME_COLOR, appName: APPS) => { - const colors = getColors(themeColor, appName); +export const getTheme = ( + themeColor: THEME_COLOR, + colorAccentType: ColorAccentType, +) => { + const colors = getColors(themeColor, colorAccentType); const palette = getPallette(themeColor, colors); const components = getComponents(colors, typography); const theme = createTheme({ diff --git a/web/packages/shared/themes/palette/index.tsx b/web/packages/shared/themes/palette.tsx similarity index 97% rename from web/packages/shared/themes/palette/index.tsx rename to web/packages/shared/themes/palette.tsx index 81d6cd5988..004d06b4cb 100644 --- a/web/packages/shared/themes/palette/index.tsx +++ b/web/packages/shared/themes/palette.tsx @@ -1,6 +1,6 @@ import { ensure } from "@/utils/ensure"; import type { PaletteOptions, ThemeColorsOptions } from "@mui/material"; -import { THEME_COLOR } from "../constants"; +import { THEME_COLOR } from "./constants"; export const getPallette = ( themeColor: THEME_COLOR, diff --git a/web/packages/shared/time/format.ts b/web/packages/shared/time/format.ts index 0380d56ff6..47a187093b 100644 --- a/web/packages/shared/time/format.ts +++ b/web/packages/shared/time/format.ts @@ -1,7 +1,5 @@ import i18n, { t } from "i18next"; -const A_DAY = 24 * 60 * 60 * 1000; - const dateTimeFullFormatter1 = new Intl.DateTimeFormat(i18n.language, { weekday: "short", month: "short", @@ -23,7 +21,7 @@ const timeFormatter = new Intl.DateTimeFormat(i18n.language, { timeStyle: "short", }); -export function formatDateFull(date: number | Date) { +function formatDateFull(date: number | Date) { return [dateTimeFullFormatter1, dateTimeFullFormatter2] .map((f) => f.format(date)) .join(" "); @@ -34,7 +32,7 @@ export function formatDate(date: number | Date) { new Date().getFullYear() === new Date(date).getFullYear(); const dateTimeFormat2 = !withinYear ? dateTimeFullFormatter2 : null; return [dateTimeFullFormatter1, dateTimeFormat2] - .filter((f) => !!f) + .filter((f): f is Intl.DateTimeFormat => !!f) .map((f) => f.format(date)) .join(" "); } @@ -54,46 +52,3 @@ export function formatDateTimeFull(dateTime: number | Date): string { export function formatDateTime(dateTime: number | Date): string { return [formatDate(dateTime), t("at"), formatTime(dateTime)].join(" "); } - -export function formatDateRelative(date: number) { - const units = { - year: 24 * 60 * 60 * 1000 * 365, - month: (24 * 60 * 60 * 1000 * 365) / 12, - day: 24 * 60 * 60 * 1000, - hour: 60 * 60 * 1000, - minute: 60 * 1000, - second: 1000, - }; - const relativeDateFormat = new Intl.RelativeTimeFormat(i18n.language, { - localeMatcher: "best fit", - numeric: "always", - style: "long", - }); - const elapsed = date - Date.now(); // "Math.abs" accounts for both "past" & "future" scenarios - - for (const u in units) - if (Math.abs(elapsed) > units[u] || u === "second") - return relativeDateFormat.format( - Math.round(elapsed / units[u]), - u as Intl.RelativeTimeFormatUnit, - ); -} - -export const isSameDay = (first, second) => { - return ( - first.getFullYear() === second.getFullYear() && - first.getMonth() === second.getMonth() && - first.getDate() === second.getDate() - ); -}; - -export const getDate = (item) => { - const currentDate = item.metadata.creationTime / 1000; - const date = isSameDay(new Date(currentDate), new Date()) - ? t("TODAY") - : isSameDay(new Date(currentDate), new Date(Date.now() - A_DAY)) - ? t("YESTERDAY") - : formatDate(currentDate); - - return date; -};