diff --git a/.cursor/rules/testing-approach.mdc b/.cursor/rules/testing-approach.mdc index f2dabfca1e..2ca37dedb9 100644 --- a/.cursor/rules/testing-approach.mdc +++ b/.cursor/rules/testing-approach.mdc @@ -24,6 +24,17 @@ Matters Web uses a combination of unit, component, and end-to-end (E2E) tests. - `npm run test:e2e`: Run end-to-end tests - `npm run test:unit:coverage`: Generate coverage report +## Release Evaluation Boundary + +Use the shared release evaluation standard when testing feature launches: + +- Preview and PR checks should use the Vercel preview URL through `PLAYWRIGHT_TEST_BASE_URL`. +- `matters.icu` is the staging acceptance target and may use disposable test data. +- `matters.town` production checks are read-only by default; production mutations, admin actions, payment actions, moderation changes, or outbound delivery require explicit human approval. +- Keep CI status, staging acceptance, production deployment, and production smoke-test results separate. + +See [docs/e2e-release-evaluation.md](mdc:docs/e2e-release-evaluation.md) for the repo-local guide. + ## Testing Utilities - Mock data and fixtures for testing diff --git a/.env.dev b/.env.dev index 4584490318..19d0ece0cf 100644 --- a/.env.dev +++ b/.env.dev @@ -19,16 +19,17 @@ NEXT_PUBLIC_OAUTH_URL=https://server.matters.icu/oauth NEXT_PUBLIC_SEGMENT_KEY=3gE20MjzN9qncFqlKV0pDvNO7Cp2gWU3 NEXT_PUBLIC_FB_APP_ID=823885921293850 NEXT_PUBLIC_WALLETCONNECT_ID=e7fc7d3ee120ec6611ea433a5bbb6613 -NEXT_PUBLIC_SENTRY_PUBLIC_KEY=5eed3e45bb994886b10e23fba64b160b -NEXT_PUBLIC_SENTRY_PROJECT_ID=6105556 -NEXT_PUBLIC_SENTRY_DOMAIN=o1089931.ingest.us.sentry.io +NEXT_PUBLIC_SENTRY_PUBLIC_KEY=5c35ff1ac0c7d9d8513ebbbb540c5dee +NEXT_PUBLIC_SENTRY_PROJECT_ID=4510956654100480 +NEXT_PUBLIC_SENTRY_DOMAIN=o4507972688478208.ingest.us.sentry.io NEXT_PUBLIC_FIREBASE_CONFIG=eyJhcGlLZXkiOiJBSXphU3lESnR4N0hZOVIxbFdNX3J0SXRwRV9jM090c3VlYVhJa28iLCJhdXRoRG9tYWluIjoibWF0dGVycy1pY3UuZmlyZWJhc2VhcHAuY29tIiwicHJvamVjdElkIjoibWF0dGVycy1pY3UiLCJzdG9yYWdlQnVja2V0IjoibWF0dGVycy1pY3UuZmlyZWJhc2VzdG9yYWdlLmFwcCIsIm1lc3NhZ2luZ1NlbmRlcklkIjoiMTI5MDg3ODY3MjgyIiwiYXBwSWQiOiIxOjEyOTA4Nzg2NzI4Mjp3ZWI6NTc3MGI1YzY5YjFiYTdjYzNjMTk0YiIsIm1lYXN1cmVtZW50SWQiOiJHLTY2VkpQRTlHMlAifQ== NEXT_PUBLIC_STRIPE_PUBLIC_KEY=pk_test_51GrOGRCE0HD6LY9Uo7rK3pSmiIG4KTjWO1rOrJavFFJob3zQlSjZg6uavIhcGbp8o1wZGcEuph2MgUfHhsB48vx000z2S0FiUx NEXT_PUBLIC_TRAVELOGGERS_URL=https://nft-develop.matters.town NEXT_PUBLIC_LOGBOOKS_URL=https://logbooks-vercel.matters.town -NEXT_PUBLIC_ALCHEMY_KEY=1dMo8xjAFo8M6Y4sQ45WTD3Zie2-MA4C +NEXT_PUBLIC_ALCHEMY_KEY=Cl3ezSQsRJ0EpAj8ar1WM NEXT_PUBLIC_GOOGLE_CLIENT_ID=315393900359-2r9fundftis7dc0tdeo2hf8630nfdd8h.apps.googleusercontent.com NEXT_PUBLIC_TWITTER_CLIENT_ID=X3d6Szg5bnVCMm5wRWxSVmhXUTc6MTpjaQ +NEXT_PUBLIC_THREADS_CLIENT_ID=1547288284064903 NEXT_PUBLIC_CF_TURNSTILE_SITE_KEY=0x4AAAAAAAKiedvR5qiLUhIs NEXT_PUBLIC_NOMAD_MATTERS_CAMPAIGN_LINK=/@rsdyobw/22048-launching-the-nomad-matters-initiative NEXT_PUBLIC_CAMPAIGN_APPLICATION_LINK=https://docs.google.com/forms/d/e/1FAIpQLSeQzWJ8g2fZk5a9DD-1P_Y4pyQCTE1cbTI6T8-5K8PQXAZ6pA/viewform?usp=dialog @@ -36,5 +37,5 @@ NEXT_PUBLIC_BILLBOARD_OPERATOR_ADDRESS=0xBcEAD54De463C083F348b530305e2471652D59A NEXT_PUBLIC_BILLBOARD_REGISTRY_ADDRESS=0xEb23327533aa993069D61b8E1d6001B1cce0E216 NEXT_PUBLIC_BILLBOARD_TOKEN_ID=1 NEXT_PUBLIC_BILLBOARD_IMAGE_URL=matters-billboard-ad-dev.s3.ap-southeast-1.amazonaws.com/ -SENTRY_ORG=matters-lab +SENTRY_ORG=firefly-uz SENTRY_PROJECT=matters-web-develop diff --git a/.env.local.example b/.env.local.example index f041ae5dec..5cfa016451 100644 --- a/.env.local.example +++ b/.env.local.example @@ -19,20 +19,20 @@ NEXT_PUBLIC_OAUTH_URL=https://server.matters.icu/oauth NEXT_PUBLIC_SEGMENT_KEY=3gE20MjzN9qncFqlKV0pDvNO7Cp2gWU3 NEXT_PUBLIC_FB_APP_ID=823885921293850 NEXT_PUBLIC_WALLETCONNECT_ID=e7fc7d3ee120ec6611ea433a5bbb6613 -NEXT_PUBLIC_SENTRY_PUBLIC_KEY=5eed3e45bb994886b10e23fba64b160b -NEXT_PUBLIC_SENTRY_PROJECT_ID=6105556 -NEXT_PUBLIC_SENTRY_DOMAIN=o1089931.ingest.us.sentry.io +NEXT_PUBLIC_SENTRY_PUBLIC_KEY=5c35ff1ac0c7d9d8513ebbbb540c5dee +NEXT_PUBLIC_SENTRY_PROJECT_ID=4510956654100480 +NEXT_PUBLIC_SENTRY_DOMAIN=o4507972688478208.ingest.us.sentry.io NEXT_PUBLIC_FIREBASE_CONFIG=eyJhcGlLZXkiOiJBSXphU3lESnR4N0hZOVIxbFdNX3J0SXRwRV9jM090c3VlYVhJa28iLCJhdXRoRG9tYWluIjoibWF0dGVycy1pY3UuZmlyZWJhc2VhcHAuY29tIiwicHJvamVjdElkIjoibWF0dGVycy1pY3UiLCJzdG9yYWdlQnVja2V0IjoibWF0dGVycy1pY3UuZmlyZWJhc2VzdG9yYWdlLmFwcCIsIm1lc3NhZ2luZ1NlbmRlcklkIjoiMTI5MDg3ODY3MjgyIiwiYXBwSWQiOiIxOjEyOTA4Nzg2NzI4Mjp3ZWI6NTc3MGI1YzY5YjFiYTdjYzNjMTk0YiIsIm1lYXN1cmVtZW50SWQiOiJHLTY2VkpQRTlHMlAifQ== NEXT_PUBLIC_STRIPE_PUBLIC_KEY=pk_test_51GrOGRCE0HD6LY9Uo7rK3pSmiIG4KTjWO1rOrJavFFJob3zQlSjZg6uavIhcGbp8o1wZGcEuph2MgUfHhsB48vx000z2S0FiUx NEXT_PUBLIC_TRAVELOGGERS_URL=https://nft-develop.matters.town NEXT_PUBLIC_LOGBOOKS_URL=https://logbooks-vercel.matters.town -NEXT_PUBLIC_ALCHEMY_KEY=1dMo8xjAFo8M6Y4sQ45WTD3Zie2-MA4C +NEXT_PUBLIC_ALCHEMY_KEY=Cl3ezSQsRJ0EpAj8ar1WM NEXT_PUBLIC_NOMAD_MATTERS_CAMPAIGN_LINK=/@rsdyobw/22048-launching-the-nomad-matters-initiative NEXT_PUBLIC_CAMPAIGN_APPLICATION_LINK=https://docs.google.com/forms/d/e/1FAIpQLSeQzWJ8g2fZk5a9DD-1P_Y4pyQCTE1cbTI6T8-5K8PQXAZ6pA/viewform?usp=dialog NEXT_PUBLIC_BILLBOARD_ADDRESS=0x6a72820E1CCCba1B1FE02E37881cEa3F9Aa6375C NEXT_PUBLIC_BILLBOARD_TOKEN_ID=6 NEXT_PUBLIC_BILLBOARD_IMAGE_URL=matters-billboard-ad-dev.s3.ap-southeast-1.amazonaws.com/ -SENTRY_ORG=matters-lab +SENTRY_ORG=firefly-uz SENTRY_PROJECT=matters-web-develop PLAYWRIGHT_RUNTIME_ENV=local PLAYWRIGHT_TEST_BASE_URL=https://matters.icu diff --git a/.env.prod b/.env.prod index 9811f834e1..5d749e049b 100644 --- a/.env.prod +++ b/.env.prod @@ -19,17 +19,18 @@ NEXT_PUBLIC_OAUTH_URL=https://server.matters.town/oauth NEXT_PUBLIC_SEGMENT_KEY=Yk2ao5JvhOCyvCh9SCVBT1iTN4kfTpy7 NEXT_PUBLIC_FB_APP_ID=1415638158583454 NEXT_PUBLIC_WALLETCONNECT_ID=e7fc7d3ee120ec6611ea433a5bbb6613 -NEXT_PUBLIC_SENTRY_PUBLIC_KEY=5af839b6d42044548d8ec70f00af8c10 -NEXT_PUBLIC_SENTRY_PROJECT_ID=6153512 -NEXT_PUBLIC_SENTRY_DOMAIN=o1089931.ingest.us.sentry.io +NEXT_PUBLIC_SENTRY_PUBLIC_KEY=6ba18c87f395dd836e1c479f2c9004f2 +NEXT_PUBLIC_SENTRY_PROJECT_ID=4510956671467520 +NEXT_PUBLIC_SENTRY_DOMAIN=o4507972688478208.ingest.us.sentry.io NEXT_PUBLIC_FIREBASE_CONFIG=eyJhcGlLZXkiOiJBSXphU3lEajFCV2tNa2tpaXdYYkpOQUFtSFVWX3hjVGZrRnQxWGciLCJhdXRoRG9tYWluIjoibWF0dGVycy0yZGQ3OC5maXJlYmFzZWFwcC5jb20iLCJkYXRhYmFzZVVSTCI6Imh0dHBzOi8vbWF0dGVycy0yZGQ3OC5maXJlYmFzZWlvLmNvbSIsInByb2plY3RJZCI6Im1hdHRlcnMtMmRkNzgiLCJzdG9yYWdlQnVja2V0IjoibWF0dGVycy0yZGQ3OC5hcHBzcG90LmNvbSIsIm1lc3NhZ2luZ1NlbmRlcklkIjoiNzE3MTM1MDcyNTcwIiwiYXBwSWQiOiIxOjcxNzEzNTA3MjU3MDp3ZWI6ODJjNWYwNjk0MmMwMjBkYThkZDQwMyIsIm1lYXN1cmVtZW50SWQiOiJHLU05VDM2MTRRSzEifQ NEXT_PUBLIC_STRIPE_PUBLIC_KEY=pk_live_NfdCmUIPzQxKEMjLkInsjULK00W7AXBBrG NEXT_PUBLIC_TRAVELOGGERS_URL=https://traveloggers.matters.town NEXT_PUBLIC_LOGBOOKS_URL=https://logbook.matters.town -NEXT_PUBLIC_ALCHEMY_KEY=bOu-fCphi9mvePsxg968Qe-pidHQNdlT +NEXT_PUBLIC_ALCHEMY_KEY=Co-LZNZhG6kpgtIKsXGY7 NEXT_PUBLIC_CF_TURNSTILE_SITE_KEY=0x4AAAAAAAKVODkJMwfIxG78 NEXT_PUBLIC_GOOGLE_CLIENT_ID=751677068109-rkml7q9ujf8ems09cclh7qckf14svcgs.apps.googleusercontent.com NEXT_PUBLIC_TWITTER_CLIENT_ID=cmdKbUlyd1ZZZDZYa3dTampidGo6MTpjaQ +NEXT_PUBLIC_THREADS_CLIENT_ID= NEXT_PUBLIC_NOMAD_MATTERS_CAMPAIGN_LINK_EN=/@hi176/476405-a-guide-to-invite NEXT_PUBLIC_NOMAD_MATTERS_CAMPAIGN_LINK=/@hi176/476404 NEXT_PUBLIC_CAMPAIGN_APPLICATION_LINK=https://docs.google.com/forms/d/e/1FAIpQLSeQzWJ8g2fZk5a9DD-1P_Y4pyQCTE1cbTI6T8-5K8PQXAZ6pA/viewform?usp=dialog @@ -37,5 +38,5 @@ NEXT_PUBLIC_BILLBOARD_OPERATOR_ADDRESS=0x92a117aeA74963Cd0CEdF9C50f99435451a291F NEXT_PUBLIC_BILLBOARD_REGISTRY_ADDRESS=0x95bEFe8E08a56dCEBBa8d40BE3e9c3cb2fF81806 NEXT_PUBLIC_BILLBOARD_TOKEN_ID=1 NEXT_PUBLIC_BILLBOARD_IMAGE_URL=matters-billboard-ad.s3.ap-southeast-1.amazonaws.com/ -SENTRY_ORG=matters-lab +SENTRY_ORG=firefly-uz SENTRY_PROJECT=matters-web-prod diff --git a/.env.prod-next b/.env.prod-next index f421f9dc02..e1576609a0 100644 --- a/.env.prod-next +++ b/.env.prod-next @@ -19,17 +19,18 @@ NEXT_PUBLIC_OAUTH_URL=https://server.matters.town/oauth NEXT_PUBLIC_SEGMENT_KEY=Yk2ao5JvhOCyvCh9SCVBT1iTN4kfTpy7 NEXT_PUBLIC_FB_APP_ID=1415638158583454 NEXT_PUBLIC_WALLETCONNECT_ID=e7fc7d3ee120ec6611ea433a5bbb6613 -NEXT_PUBLIC_SENTRY_PUBLIC_KEY=5af839b6d42044548d8ec70f00af8c10 -NEXT_PUBLIC_SENTRY_PROJECT_ID=6153512 -NEXT_PUBLIC_SENTRY_DOMAIN=o1089931.ingest.us.sentry.io +NEXT_PUBLIC_SENTRY_PUBLIC_KEY=6ba18c87f395dd836e1c479f2c9004f2 +NEXT_PUBLIC_SENTRY_PROJECT_ID=4510956671467520 +NEXT_PUBLIC_SENTRY_DOMAIN=o4507972688478208.ingest.us.sentry.io NEXT_PUBLIC_FIREBASE_CONFIG=eyJhcGlLZXkiOiJBSXphU3lEajFCV2tNa2tpaXdYYkpOQUFtSFVWX3hjVGZrRnQxWGciLCJhdXRoRG9tYWluIjoibWF0dGVycy0yZGQ3OC5maXJlYmFzZWFwcC5jb20iLCJkYXRhYmFzZVVSTCI6Imh0dHBzOi8vbWF0dGVycy0yZGQ3OC5maXJlYmFzZWlvLmNvbSIsInByb2plY3RJZCI6Im1hdHRlcnMtMmRkNzgiLCJzdG9yYWdlQnVja2V0IjoibWF0dGVycy0yZGQ3OC5hcHBzcG90LmNvbSIsIm1lc3NhZ2luZ1NlbmRlcklkIjoiNzE3MTM1MDcyNTcwIiwiYXBwSWQiOiIxOjcxNzEzNTA3MjU3MDp3ZWI6ODJjNWYwNjk0MmMwMjBkYThkZDQwMyIsIm1lYXN1cmVtZW50SWQiOiJHLU05VDM2MTRRSzEifQ NEXT_PUBLIC_STRIPE_PUBLIC_KEY=pk_live_NfdCmUIPzQxKEMjLkInsjULK00W7AXBBrG NEXT_PUBLIC_TRAVELOGGERS_URL=https://traveloggers.matters.town NEXT_PUBLIC_LOGBOOKS_URL=https://logbook.matters.town -NEXT_PUBLIC_ALCHEMY_KEY=bOu-fCphi9mvePsxg968Qe-pidHQNdlT +NEXT_PUBLIC_ALCHEMY_KEY=Co-LZNZhG6kpgtIKsXGY7 NEXT_PUBLIC_CF_TURNSTILE_SITE_KEY=0x4AAAAAAAKVODkJMwfIxG78 NEXT_PUBLIC_GOOGLE_CLIENT_ID=751677068109-rkml7q9ujf8ems09cclh7qckf14svcgs.apps.googleusercontent.com NEXT_PUBLIC_TWITTER_CLIENT_ID=cmdKbUlyd1ZZZDZYa3dTampidGo6MTpjaQ +NEXT_PUBLIC_THREADS_CLIENT_ID= NEXT_PUBLIC_NOMAD_MATTERS_CAMPAIGN_LINK_EN=/@hi176/476405-a-guide-to-invite NEXT_PUBLIC_NOMAD_MATTERS_CAMPAIGN_LINK=/@hi176/476404 NEXT_PUBLIC_CAMPAIGN_APPLICATION_LINK=https://docs.google.com/forms/d/e/1FAIpQLSeQzWJ8g2fZk5a9DD-1P_Y4pyQCTE1cbTI6T8-5K8PQXAZ6pA/viewform?usp=dialog @@ -40,5 +41,5 @@ NEXT_PUBLIC_BILLBOARD_IMAGE_URL=matters-billboard-ad.s3.ap-southeast-1.amazonaws NEXT_PUBLIC_SITE_DOMAIN=web-next.matters.town NEXT_PUBLIC_ADMIN_VIEW=true -SENTRY_ORG=matters-lab +SENTRY_ORG=firefly-uz SENTRY_PROJECT=matters-web-prod diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d570887829..f59ba93f35 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -18,7 +18,7 @@ env: AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} - SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN_V2 }} jobs: build_and_deploy: @@ -118,7 +118,7 @@ jobs: - name: Generate deployment package if: github.base_ref == 'develop' || github.base_ref == 'master' || github.base_ref == 'main' || github.base_ref == 'release/next' run: | - zip -r --symlinks deploy.zip . -x .git/\* node_modules/\* + zip -r --symlinks deploy.zip . -x .git/\* node_modules/\* .next/cache/\* - name: Upload Assets (develop - R2) if: github.base_ref == 'develop' diff --git a/.github/workflows/set-develop-personhood-env.yml b/.github/workflows/set-develop-personhood-env.yml new file mode 100644 index 0000000000..daa964318b --- /dev/null +++ b/.github/workflows/set-develop-personhood-env.yml @@ -0,0 +1,93 @@ +name: Set Develop Personhood Env + +on: + workflow_dispatch: + inputs: + enabled: + description: 'Enable TW FidO personhood routes on matters.icu' + required: true + default: 'true' + type: choice + options: + - 'true' + - 'false' + return_proof_input: + description: 'Return verifier proof input to the mobile helper' + required: true + default: 'true' + type: choice + options: + - 'true' + - 'false' + verifier_url: + description: 'zkID verifier base URL for matters.icu' + required: true + type: string + +concurrency: + group: set-develop-personhood-env + cancel-in-progress: false + +jobs: + set-develop-env: + name: Set matters.icu develop personhood env + runs-on: ubuntu-latest + environment: develop + permissions: + contents: read + steps: + - name: Validate input + run: | + case "${{ inputs.enabled }}" in + true|false) ;; + *) echo "Unsupported enabled value: ${{ inputs.enabled }}" >&2; exit 1 ;; + esac + + case "${{ inputs.return_proof_input }}" in + true|false) ;; + *) echo "Unsupported return_proof_input value: ${{ inputs.return_proof_input }}" >&2; exit 1 ;; + esac + + node -e "const url = new URL(process.argv[1]); if (url.protocol !== 'https:') throw new Error('verifier_url must be https')" "${{ inputs.verifier_url }}" + + if [ -z "${{ secrets.PERSONHOOD_FIDO_SP_SERVICE_ID }}" ]; then + echo "Missing PERSONHOOD_FIDO_SP_SERVICE_ID environment secret" >&2 + exit 1 + fi + + if [ -z "${{ secrets.PERSONHOOD_FIDO_AES_KEY_BASE64 }}" ]; then + echo "Missing PERSONHOOD_FIDO_AES_KEY_BASE64 environment secret" >&2 + exit 1 + fi + + - name: Setup AWS + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.AWS_REGION_DEV }} + + - name: Update develop Elastic Beanstalk environment + run: | + aws elasticbeanstalk update-environment \ + --environment-name "${{ secrets.AWS_EB_ENV_NAME_DEV }}" \ + --option-settings \ + Namespace=aws:elasticbeanstalk:application:environment,OptionName=PERSONHOOD_TW_FIDO_ENABLED,Value="${{ inputs.enabled }}" \ + Namespace=aws:elasticbeanstalk:application:environment,OptionName=PERSONHOOD_TW_FIDO_RETURN_PROOF_INPUT,Value="${{ inputs.return_proof_input }}" \ + Namespace=aws:elasticbeanstalk:application:environment,OptionName=PERSONHOOD_FIDO_SP_SERVICE_ID,Value="${{ secrets.PERSONHOOD_FIDO_SP_SERVICE_ID }}" \ + Namespace=aws:elasticbeanstalk:application:environment,OptionName=PERSONHOOD_FIDO_AES_KEY_BASE64,Value="${{ secrets.PERSONHOOD_FIDO_AES_KEY_BASE64 }}" \ + Namespace=aws:elasticbeanstalk:application:environment,OptionName=PERSONHOOD_VERIFIER_URL,Value="${{ inputs.verifier_url }}" \ + >/tmp/update-environment.json + + - name: Wait for develop environment + run: | + aws elasticbeanstalk wait environment-updated \ + --environment-names "${{ secrets.AWS_EB_ENV_NAME_DEV }}" + + - name: Verify develop setting + run: | + aws elasticbeanstalk describe-configuration-settings \ + --application-name matters-stage \ + --environment-name "${{ secrets.AWS_EB_ENV_NAME_DEV }}" \ + --query "ConfigurationSettings[0].OptionSettings[?Namespace=='aws:elasticbeanstalk:application:environment' && contains(['PERSONHOOD_TW_FIDO_ENABLED','PERSONHOOD_TW_FIDO_RETURN_PROOF_INPUT','PERSONHOOD_FIDO_SP_SERVICE_ID','PERSONHOOD_VERIFIER_URL'], OptionName)].{OptionName:OptionName,Value:Value}" \ + --output table diff --git a/README.md b/README.md index 2565fa9046..4b7308c651 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,9 @@ ## Testing -See [Playwright Testing Guide](https://www.notion.so/matterslab/Playwright-Testing-Guide-60caa248d5ce4d70938b7b2f2c7e9139). +See [E2E Release Evaluation](docs/e2e-release-evaluation.md) for the repo-local Playwright and release-gate guide. + +The older Notion guide is still useful for background context: [Playwright Testing Guide](https://www.notion.so/matterslab/Playwright-Testing-Guide-60caa248d5ce4d70938b7b2f2c7e9139). ## Conventions diff --git a/configs/csp.ts b/configs/csp.ts index cacf4572e9..94f20ed27d 100644 --- a/configs/csp.ts +++ b/configs/csp.ts @@ -13,8 +13,21 @@ export const SENTRY_CSP_REPORT_GROUP = 'csp-endpoint' const DEFAULT_SRC = ["'self'", process.env.NEXT_PUBLIC_NEXT_ASSET_DOMAIN] +const getCspHost = (url?: string) => { + if (!url) { + return undefined + } + + try { + return new URL(url).hostname + } catch { + return url + } +} + const SCRIPT_SRC = [ "'self'", + "'wasm-unsafe-eval'", // Next.js Assets process.env.NEXT_PUBLIC_NEXT_ASSET_DOMAIN, @@ -121,6 +134,8 @@ const CONNECT_SRC = [ // Next.js Assets process.env.NEXT_PUBLIC_NEXT_ASSET_DOMAIN, + getCspHost(process.env.NEXT_PUBLIC_PERSONHOOD_ASSET_URL), + getCspHost(process.env.NEXT_PUBLIC_PERSONHOOD_VERIFIER_URL), // API process.env.NEXT_PUBLIC_API_URL, @@ -213,6 +228,8 @@ const PREFETCH_SRC = [ process.env.NEXT_PUBLIC_NEXT_ASSET_DOMAIN, ] +const WORKER_SRC = ["'self'", 'blob:'] + export const CSP_POLICY = Object.entries({ 'default-src': DEFAULT_SRC, 'script-src': SCRIPT_SRC, @@ -223,6 +240,7 @@ export const CSP_POLICY = Object.entries({ 'connect-src': CONNECT_SRC, 'frame-src': FRAME_SRC, 'prefetch-src': PREFETCH_SRC, + 'worker-src': WORKER_SRC, 'report-uri': SENTRY_REPORT_URI, 'report-to': SENTRY_CSP_REPORT_GROUP, }) diff --git a/docs/e2e-release-evaluation.md b/docs/e2e-release-evaluation.md new file mode 100644 index 0000000000..f5d2445e3a --- /dev/null +++ b/docs/e2e-release-evaluation.md @@ -0,0 +1,174 @@ +# E2E Release Evaluation + +This document explains how `matters-web` Playwright tests fit into the shared Matters release-evaluation standard. + +The full standard lives in `thematters/matters-release-evaluation-agent`. This repo keeps the product-specific E2E entry points and environment details. + +## Core Rule + +Do not treat CI success, staging success, production deployment, and production approval as the same gate. + +For `matters-web`, report these separately: + +- Build and unit test status. +- Playwright E2E status. +- Preview URL result. +- `matters.icu` staging acceptance result. +- Production approval state. +- `matters.town` production smoke result. + +## Required Role Matrix + +Every staging acceptance and production smoke must include three viewer states: + +| Viewer State | Required Checks | +| ---------------------- | ------------------------------------------------------------------------------- | +| Logged-out visitor | Homepage, article page, and public profile render without GraphQL auth errors. | +| Normal logged-in user | Homepage, article page, public profile, notifications entry, and own draft/settings pages render without GraphQL auth errors. | +| Admin or staff account | Admin-only controls render only in admin view and do not leak into normal web. | + +This matrix is required even when the feature under release targets only admins or a trusted group. Global viewer queries, navigation, and shared dropdowns can still affect normal users. + +## GraphQL Permission Boundary + +Frontend queries that run in normal web routes must not read `oss` fields or other admin-only schema fields. In particular: + +- Do not use `viewer.oss`, `user.oss`, `article.oss`, `comment.oss`, or `moment.oss` in shared viewer initialization or normal user routes. +- Do not use `UserFeatureFlagType` as a frontend permission source outside admin-only management surfaces. +- For a normal-user UI gate, use a public-safe field or add a dedicated safe GraphQL field on the server. +- Server mutations must still perform the final permission check; frontend gating is only presentation. + +Admin-only `oss` queries are allowed only when both conditions are true: the component is rendered behind `NEXT_PUBLIC_ADMIN_VIEW === 'true'`, and the viewer is checked as admin before the component is mounted. + +## Existing E2E Surface + +- Specs: `tests/*.spec.ts` +- Playwright config: `playwright.config.ts` +- Auth setup: `tests/sessions.setup.ts` +- GitHub Actions workflow: `.github/workflows/test_e2e.yml` + +Useful commands: + +```bash +npm run test:unit +npm run build +npm run test:e2e +npm run test:e2e:smoke +npm run test:e2e:staging +npm run test:e2e:mutation +npm run test:e2e:prod-smoke +``` + +Playwright target variables: + +```bash +PLAYWRIGHT_TEST_BASE_URL= +PLAYWRIGHT_TEST_API_URL= +``` + +The E2E workflow already uses a Vercel preview URL as `PLAYWRIGHT_TEST_BASE_URL` and uploads the Playwright HTML report. + +## Environment Targets + +| Layer | Web URL | GraphQL URL | Default Policy | +| ---------- | ---------------------- | -------------------------------------------- | ------------------------------------------------------------- | +| Preview | Vercel preview URL | Usually `https://server.matters.icu/graphql` | PR-level browser regression with test accounts. | +| Staging | `https://matters.icu` | `https://server.matters.icu/graphql` | Full acceptance; disposable staging mutation data is allowed. | +| Production | `https://matters.town` | `https://server.matters.town/graphql` | Read-only smoke by default. | + +Production tests must not create articles, update settings, run admin actions, touch payments, change moderation state, or send outbound federation unless explicit human approval is recorded. + +Production smoke should assert visible page state, not network quiescence. Public production pages may keep analytics, ads, or other long-running requests open, so `networkidle` alone is not a reliable health signal. + +## Recommended Tag Model + +Existing specs are tagged so they can be selected as automated release gates across preview, staging, and production. + +| Tag | Meaning | Production Default | +| ----------------- | ---------------------------------------------- | ------------------------------ | +| `@smoke` | Read-only core route and rendering checks. | Allowed. | +| `@auth-smoke` | Login/session checks using safe test accounts. | Approval required. | +| `@regression` | Broader user journeys. | Usually not run on production. | +| `@mutation` | Creates or edits content/settings. | Not allowed by default. | +| `@admin` | Staff/admin permission checks. | Not allowed by default. | +| `@payment` | Payment/support flow. | Not allowed by default. | +| `@feature:` | Feature-specific checks. | Depends on other tags. | + +Current first pass: + +| File | Suggested Tags | Notes | +| ---------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------ | +| `tests/homepage.spec.ts` | `@smoke`, `@regression` | Keep production smoke to critical page/feed rendering only; sidebar shuffle and other volatile controls belong in regression. | +| `tests/authentication.spec.ts` | `@auth-smoke`, `@mutation` for OTP/account creation | Account creation must not run on production by default. | +| `tests/commentArticle.spec.ts` | `@mutation` | Staging only by default. | +| `tests/supportArticle.spec.ts` | `@payment`, `@mutation` | Needs payment test boundary and approval. | +| `tests/mutateUser.spec.ts` | `@mutation` | Staging only by default. | +| `tests/switchBetweenUsers.spec.ts` | `@auth-smoke`, `@regression`, `@mutation` | Sends comments, so production is blocked by default. | +| `tests/mutateArticle.spec.ts` | `@mutation` | Currently ignored by Playwright config, but tagged for future use. | + +## Tagged Scripts + +These scripts select tagged test profiles: + +```json +{ + "test:e2e:smoke": "playwright test --grep @smoke --no-deps", + "test:e2e:staging": "playwright test --grep \"@smoke|@auth-smoke|@regression\"", + "test:e2e:mutation": "playwright test --grep @mutation", + "test:e2e:prod-smoke": "playwright test --grep @smoke --grep-invert \"@mutation|@admin|@payment\" --no-deps" +} +``` + +Do not add production mutation scripts unless the workflow also enforces an explicit approval flag. + +`--no-deps` is intentional for read-only smoke profiles. The default Chromium project depends on `tests/sessions.setup.ts`, and that setup logs in and updates test account language through `PLAYWRIGHT_TEST_API_URL`. Production read-only smoke must avoid that setup unless explicit production account mutation approval exists. + +## Example Runs + +Preview: + +```bash +PLAYWRIGHT_TEST_BASE_URL= \ +PLAYWRIGHT_TEST_API_URL=https://server.matters.icu/graphql \ +npm run test:e2e +``` + +Staging: + +```bash +PLAYWRIGHT_TEST_BASE_URL=https://matters.icu \ +PLAYWRIGHT_TEST_API_URL=https://server.matters.icu/graphql \ +npm run test:e2e +``` + +Production read-only smoke after tags are implemented: + +```bash +PLAYWRIGHT_TEST_BASE_URL=https://matters.town \ +PLAYWRIGHT_TEST_API_URL=https://server.matters.town/graphql \ +npm run test:e2e:prod-smoke +``` + +## Required Release Report Evidence + +Every release evaluation should record: + +- Target web URL. +- Target GraphQL URL. +- Branch, PR, or commit under test. +- Commands run. +- Playwright report path or URL. +- Browser-visible evidence for UI changes. +- Created staging data IDs or URLs, if any. +- Blockers classified as code, environment, credential, data, deploy, external platform, or approval. +- Whether production approval has been requested or granted. + +## Stop Conditions + +Stop and report a blocker when: + +- Preview cannot load. +- Required GraphQL fields or mutations are missing. +- The Playwright base URL points to production while the selected test creates or edits data. +- Test accounts or credentials are missing. +- A production-risk action is required but no explicit approval exists. diff --git a/docs/personhood-proving-rollout.md b/docs/personhood-proving-rollout.md new file mode 100644 index 0000000000..3096e46f7e --- /dev/null +++ b/docs/personhood-proving-rollout.md @@ -0,0 +1,43 @@ +# Personhood Proving Rollout + +## Current split + +The personhood rollout now has two proving lanes. + +1. Desktop browser proving + - TW FidO signing still starts on the phone. + - Matters creates the zkID handoff after the phone returns with a signature. + - The user can copy a Mac proof link and open it on a desktop browser. + - The handoff is carried in the URL fragment, so it is not sent in the HTTP request for the isolated prover page. + - The isolated prover imports the fragment into local storage, removes the fragment from browser history, and runs the browser proof. + +2. iPhone Safari proving + - iPhone Safari exposes the required APIs on the isolated page. + - The current RS4096 browser prover is not safe to run there because the proof key and wasm allocations can trigger WebKit WebContent process termination. + - The iPhone path is guarded so users do not repeatedly hit Safari's reload loop. + - `?force=1` remains available only for controlled debugging. + +## Why iPhone is blocked + +The current proof path needs a 693 MiB decompressed cert-chain proving key plus wasm runtime and witness memory. Even after streaming the key and proving sequentially, iPhone Safari still reloads the page and eventually shows "A problem repeatedly occurred" on the isolated prover URL. This behavior matches WebKit WebContent memory termination rather than a normal JavaScript exception. + +Relevant external notes: + +- Apple Developer Forums have recent reports of `com.apple.WebKit.WebContent` being killed by iOS `memorystatus` after hitting an ActiveHard memory limit around 2048 MiB: https://developer.apple.com/forums/thread/823061 +- WebKit memory analysis from Catch Metrics describes iOS WebKit page termination by Jetsam under high memory pressure: https://www.catchmetrics.io/blog/deep-dive-ram-internals-webkit +- Existing WebAssembly issue reports from Godot and Emscripten show iOS Safari failures when wasm memory is configured too high or repeatedly loaded: https://github.com/godotengine/godot/issues/70621 and https://github.com/emscripten-core/emscripten/issues/19374 + +## Next iPhone options + +Recommended order: + +1. Keep iPhone as TW FidO signer plus handoff carrier, and use desktop browser proof for develop testing. +2. Measure the exact high-water memory on desktop and Android Chrome for the current circuits, then set a browser support matrix. +3. Prototype a server-side proving queue where the server receives only the already-minimized zkID private witness inputs needed for proof generation, never the raw ID number. +4. Revisit iPhone browser proving only if the zkID circuit or proving key can be reduced enough to stay well below iOS WebContent limits. + +Non-goals for the next slice: + +- Do not ask general users to install Xcode or a native helper app. +- Do not keep retrying the current full RS4096 browser proof on iPhone Safari. +- Do not send TW FidO `signed_response` through query strings or server logs. diff --git a/lang/default.json b/lang/default.json index 2df622cbe9..b540a94307 100644 --- a/lang/default.json +++ b/lang/default.json @@ -9,10 +9,6 @@ "defaultMessage": "Please verify email first", "description": "src/views/Me/Settings/Settings/Password/index.tsx" }, - "+5O5ev": { - "defaultMessage": "{circleName}", - "description": "src/views/ArticleDetail/Wall/Circle/index.tsx" - }, "+5sU+5": { "defaultMessage": "Tort" }, @@ -32,6 +28,10 @@ "+GAaxB": { "defaultMessage": "The article has been archived due to violation of terms" }, + "+Gk0O1": { + "defaultMessage": "操作失敗,請稍後再試", + "description": "src/components/Comment/DropdownActions/CommunityWatchRemoveComment.tsx" + }, "+IYfVH": { "defaultMessage": "Remove from event featured" }, @@ -103,6 +103,9 @@ "/GyMKa": { "defaultMessage": "Share Article" }, + "/H/Ei0": { + "defaultMessage": "Copy report" + }, "/IMR+8": { "defaultMessage": "Top Supporters" }, @@ -165,6 +168,10 @@ "0PpH2v": { "defaultMessage": "Authorize in Wallet" }, + "0SLJni": { + "defaultMessage": "所有處理都會公開留痕", + "description": "src/components/Comment/DropdownActions/index.tsx" + }, "0SQatS": { "defaultMessage": "View all", "description": "src/views/CampaignDetail/SideParticipants/index.tsx" @@ -420,6 +427,10 @@ "4CrCbD": { "defaultMessage": "Community" }, + "4E1vEv": { + "defaultMessage": "色情廣告", + "description": "src/components/Comment/DropdownActions/CommunityWatchRemoveComment.tsx" + }, "4KuZ0o": { "defaultMessage": "Asset not found", "description": "ASSET_NOT_FOUND" @@ -479,6 +490,12 @@ "5XFd/5": { "defaultMessage": "Manage Circle" }, + "5YuSaZ": { + "defaultMessage": "After TW FidO signs, continue here on desktop, or copy the Mac proof link to a desktop browser." + }, + "5fjmnA": { + "defaultMessage": "碳基生物" + }, "5iii3x": { "defaultMessage": "Circle:", "description": "src/components/CircleDigest/UserProfile/index.tsx" @@ -595,6 +612,9 @@ "defaultMessage": "Edit", "description": "src/components/CircleComment/DropdownActions/EditButton.tsx" }, + "6wdAti": { + "defaultMessage": "No moments yet" + }, "73iajM": { "defaultMessage": "Oops. Something went wrong. Please try again later.", "description": "BAD_USER_INPUT" @@ -606,6 +626,9 @@ "defaultMessage": "Payout Canceled", "description": "src/components/Transaction/index.tsx" }, + "7DJ8wF": { + "defaultMessage": "Join the Moments channel" + }, "7HPPqs": { "defaultMessage": ". {SuggestButton}?" }, @@ -714,6 +737,9 @@ "defaultMessage": "Edit", "description": "src/components/CircleComment/DropdownActions/index.tsx" }, + "90M7k0": { + "defaultMessage": "馬特市守望相助隊" + }, "91AzwP": { "defaultMessage": "Only JPEG, PNG, and GIF and WebP images are supported." }, @@ -800,6 +826,9 @@ "defaultMessage": "Payment suspended or returned by card issuer when there are doubts about the transaction", "description": "src/components/Transaction/index.tsx" }, + "ABDljA": { + "defaultMessage": "Create ticket" + }, "AEiogR": { "defaultMessage": "This Google account is connected to a Matters account. Sign in to that account to disconnect it then try again", "description": "USER_SOCIAL_ACCOUNT_EXISTS" @@ -911,6 +940,9 @@ "C/4nS6": { "defaultMessage": "Connect and claim" }, + "C1yL+d": { + "defaultMessage": "TW FidO mobile flow" + }, "C3NKBg": { "defaultMessage": "Connected to {type}", "description": "src/views/Me/Settings/Settings/Socials/index.tsx" @@ -926,6 +958,9 @@ "defaultMessage": "bookmarked", "description": "src/components/Notice/ArticleNotice/ArticleNewSubscriberNotice.tsx" }, + "CFtbZu": { + "defaultMessage": "Run checks" + }, "CNMDU5": { "defaultMessage": "ID must be between {MIN_USER_NAME_LENGTH} and {MAX_USER_NAME_LENGTH} characters long" }, @@ -989,6 +1024,9 @@ "D9tEst": { "defaultMessage": "Matters Architect" }, + "DCgQVz": { + "defaultMessage": "PWA feasibility" + }, "DP3yqI": { "defaultMessage": "Donation count" }, @@ -999,6 +1037,9 @@ "DUvMii": { "defaultMessage": "Customize your call-to-support prompt to audience, or thank-you card for those who supported you." }, + "DW68ct": { + "defaultMessage": "Browser signals for the carbon based badge prover." + }, "DX0YH3": { "defaultMessage": "Wallet successfully bound", "description": "src/components/Forms/PaymentForm/BindWallet/index.tsx" @@ -1014,6 +1055,9 @@ "DjIpR6": { "defaultMessage": "Set Cover" }, + "DnOjES": { + "defaultMessage": "關閉" + }, "DoOt1q": { "defaultMessage": "Your channel suggestion has been accepted by the editor. Thank you for supporting Matters!", "description": "src/components/Notice/ArticleNotice/TopicChannelFeedbackAcceptedNotice.tsx" @@ -1036,6 +1080,9 @@ "DtO278": { "defaultMessage": "We’ve detected that several of your recent works have been recommended to related channels. They may not appear at the same time" }, + "Dx5Sas": { + "defaultMessage": "Copy Mac proof link" + }, "DyuHBH": { "defaultMessage": "Unpin from profile", "description": "src/components/CollectionDigest/DropdownActions/PinButton.tsx" @@ -1140,6 +1187,9 @@ "defaultMessage": "Switch to Optimism network now?", "description": "src/components/Forms/PaymentForm/SwitchNetwork/index.tsx" }, + "FXgOAP": { + "defaultMessage": "Browser handoff is ready. Browser proving is pending until the prover runs in a cross-origin isolated page." + }, "Fe682o": { "defaultMessage": "Next Month (Estimation)", "description": "src/views/Circle/Analytics/IncomeAnalytics/index.tsx" @@ -1164,6 +1214,9 @@ "defaultMessage": "This ID cannot be modified. Are you sure you want to use {id} as your Matters ID?", "description": "src/components/Dialogs/SetUserNameDialog/ConfirmStep.tsx" }, + "G/jeUL": { + "defaultMessage": "Open TW FidO" + }, "G/yZLu": { "defaultMessage": "Remove" }, @@ -1307,6 +1360,12 @@ "HzB4Lk": { "defaultMessage": "Tell readers why you edited this time..." }, + "I1eVEY": { + "defaultMessage": "Application submitted, pending review" + }, + "I3v/Va": { + "defaultMessage": "PWA proof handoff" + }, "IJ9YcQ": { "defaultMessage": "Unlink", "description": "src/components/Editor" @@ -1501,6 +1560,9 @@ "LWE7oq": { "defaultMessage": "Saving draft, are you sure you want to leave?" }, + "LXZyce": { + "defaultMessage": "You have already applied, please wait for the review." + }, "Lb0JsC": { "defaultMessage": "You have blocked that user" }, @@ -1564,6 +1626,9 @@ "defaultMessage": "Confirm in Wallet", "description": "src/components/Forms/PaymentForm/PayTo/Confirm/index.tsx" }, + "N73cBw": { + "defaultMessage": "WASM memory" + }, "NACY16": { "defaultMessage": "Why need to set up a wallet?", "description": "src/components/Forms/PaymentForm/BindWallet/index.tsx" @@ -1614,6 +1679,9 @@ "defaultMessage": "After deletion, the {commentType} will be removed immediately", "description": "src/components/CircleComment/DropdownActions/DeleteComment/Dialog.tsx" }, + "NloKwX": { + "defaultMessage": "Proof worker" + }, "NmhF45": { "defaultMessage": "Adding tags helps readers find your articles. Add or create new tags." }, @@ -1751,6 +1819,9 @@ "Q8Qw5B": { "defaultMessage": "Description" }, + "QHRze5": { + "defaultMessage": "Moments" + }, "QKJWqd": { "defaultMessage": "Bookmark", "description": "src/components/Buttons/TagBookmark/Bookmark.tsx" @@ -1826,6 +1897,9 @@ "defaultMessage": "Please confirm transaction password", "description": "src/components/Forms/PaymentForm/SetPassword/index.tsx" }, + "RYc4X0": { + "defaultMessage": "Browser proof" + }, "Rc4Oij": { "defaultMessage": "Firebolt" }, @@ -1911,6 +1985,9 @@ "Sj+TN8": { "defaultMessage": "Announcement" }, + "Su1LcW": { + "defaultMessage": "開啟後,新發佈作品預設可輸出到 Fediverse。" + }, "SuRTsQ": { "defaultMessage": "Register for ISCN" }, @@ -2008,6 +2085,9 @@ "USOHRK": { "defaultMessage": "Failed to edit, please try again." }, + "USq+mM": { + "defaultMessage": "Pornographic advertising" + }, "UUJzml": { "defaultMessage": "Logbook 2.0 has just launched. If you are an owner of Traveloggers, and haven't claimed, you may claim one from the new Logbook page:" }, @@ -2051,6 +2131,12 @@ "VBve8d": { "defaultMessage": "Settled in Matters" }, + "VDclc3": { + "defaultMessage": "You have joined the Moments channel. The moments you post will now appear in this channel." + }, + "VFQGq7": { + "defaultMessage": "Check result" + }, "VMQPwZ": { "defaultMessage": "Ended", "description": "src/views/Campaigns/Feeds/Tabs/index.tsx" @@ -2244,6 +2330,9 @@ "Y9IYvj": { "defaultMessage": "No suitable channels" }, + "YC2b3b": { + "defaultMessage": "Fediverse 聯邦發佈" + }, "YDMrKK": { "defaultMessage": "Users" }, @@ -2276,6 +2365,9 @@ "defaultMessage": "Archived for violation.", "description": "src/views/ArticleDetail/StickyTopBanner/index.tsx" }, + "ZAoAcG": { + "defaultMessage": "Threads, Mastodon, Misskey 這些地方的粉絲也會看到" + }, "ZAs170": { "defaultMessage": "Profile", "description": "src/views/Circle/Settings/index.tsx" @@ -2320,6 +2412,10 @@ "defaultMessage": "Number of readers: unique registered users plus number of anonymous IP addresses visited the article (Data will be updated periodically and may be delayed)", "description": "src/views/Me/Works/Published/SortTabs.tsx" }, + "a5ZQOK": { + "defaultMessage": "查看公開紀錄", + "description": "src/components/Comment/DropdownActions/CommunityWatchRemoveComment.tsx" + }, "aCTmEO": { "defaultMessage": "I don't have a wallet yet", "description": "src/components/Forms/SelectAuthMethodForm/WalletFeed.tsx" @@ -2368,6 +2464,9 @@ "defaultMessage": "go to the homepage", "description": "src/views/Callback/UI.tsx" }, + "atmn17": { + "defaultMessage": "Open isolated prover" + }, "awW+lk": { "defaultMessage": "Processing", "description": "src/components/Transaction/State/index.tsx" @@ -2424,6 +2523,9 @@ "defaultMessage": "pinned your comment in {commentArticle}", "description": "src/components/Notice/CommentNotice/CommentPinnedNotice.tsx" }, + "cB6zjC": { + "defaultMessage": "Signed TW FidO proof input is kept in this browser for the next proving step." + }, "cCpbBu": { "defaultMessage": "Popular Channel Authors" }, @@ -2502,6 +2604,10 @@ "d5bM8A": { "defaultMessage": "Select Date..." }, + "d95AX1": { + "defaultMessage": "Let me think about it", + "description": "src/views/HottestMoments/Apply/Dialog/index.tsx" + }, "dAPUJp": { "defaultMessage": "The dazzling light of a meteor shower is enough to illuminate the night sky. The Meteor Canoe badge signifies your participation in the Nomad Matters.", "description": "src/views/User/UserProfile/BadgeNomadLabel/index.tsx" @@ -2538,6 +2644,9 @@ "defaultMessage": "Comments and replies", "description": "src/views/Me/Settings/Notifications/GeneralSettings/index.tsx" }, + "dWt8c/": { + "defaultMessage": "Open the isolated browser container. It does not load the normal Matters bundle, so cross-origin isolation can be enabled for the zkID worker." + }, "dZlT9q": { "defaultMessage": "Your work has been recommended to the channels: {channelNames}. Are you satisfied with the result?" }, @@ -2574,6 +2683,10 @@ "eNv3Wm": { "defaultMessage": "Your work has been recommended to the channels: {channelNames}" }, + "eRTBgt": { + "defaultMessage": "本則貼文已由守望相助隊檢舉", + "description": "src/components/Comment/Content/index.tsx" + }, "eTpiYa": { "defaultMessage": "No data yet." }, @@ -2653,6 +2766,9 @@ "fWZYP5": { "defaultMessage": "Pinned" }, + "fbxogW": { + "defaultMessage": "Personhood" + }, "fko+MR": { "defaultMessage": "Retry claiming" }, @@ -2771,10 +2887,18 @@ "defaultMessage": "Insufficient:", "description": "src/components/Balance/index.tsx" }, + "hdbKK1": { + "defaultMessage": "Moments", + "description": "src/components/Layout/SideChannelNav/index.tsx" + }, "hgtWIO": { "defaultMessage": "Articles have been collected", "description": "src/views/Me/Settings/Notifications/GeneralSettings/index.tsx" }, + "hifvI7": { + "defaultMessage": "Circle locked work", + "description": "src/views/ArticleDetail/Wall/Circle/index.tsx" + }, "hk2aiz": { "defaultMessage": "followed your circle", "description": "src/components/Notice/CircleNotice/CircleNewUserNotice.tsx" @@ -2912,10 +3036,6 @@ "kHMa3H": { "defaultMessage": "The new password and confirmation password do not match." }, - "kPylKK": { - "defaultMessage": "Circle", - "description": "src/views/Me/Settings/Notifications/index.tsx" - }, "kS3vTS": { "defaultMessage": "Liker ID", "description": "src/views/Me/Settings/Misc/LikerID.tsx" @@ -2931,6 +3051,9 @@ "defaultMessage": "Remove", "description": "src/components/Dialogs/RemoveArticleCollectionDialog/index.tsx" }, + "kqQp7b": { + "defaultMessage": "No signed proof input was found. Return to the TW FidO step and check the result again." + }, "ksIL/T": { "defaultMessage": "Kind reminder: This wallet address is different from the wallet address you use to log in to Matters" }, @@ -2980,6 +3103,9 @@ "lO7wKc": { "defaultMessage": "Not yet" }, + "lTleCS": { + "defaultMessage": "Checking" + }, "lYVn31": { "defaultMessage": "This work has been added to the schedule. Please go to the \"My Works\" page to confirm" }, @@ -3027,6 +3153,9 @@ "defaultMessage": "Hottest", "description": "src/views/Circle/Analytics/ContentAnalytics/index.tsx" }, + "mFn/Vv": { + "defaultMessage": "已更新 Fediverse 設定" + }, "mJEqC/": { "defaultMessage": "Go to {href}" }, @@ -3034,6 +3163,9 @@ "defaultMessage": "Remove bookmark", "description": "src/components/Buttons/TagBookmark/Unbookmark.tsx" }, + "mMji02": { + "defaultMessage": "開啟" + }, "mPe6DK": { "defaultMessage": "subscribed your circle", "description": "src/components/Notice/CircleNotice/CircleNewUserNotice.tsx" @@ -3137,6 +3269,10 @@ "defaultMessage": "Invalid Email", "description": "USER_EMAIL_INVALID" }, + "oA2Pur": { + "defaultMessage": "濫發廣告", + "description": "src/components/Comment/DropdownActions/CommunityWatchRemoveComment.tsx" + }, "oEHAIT": { "defaultMessage": "Cancel schedule", "description": "confirm cancel schedule button" @@ -3159,6 +3295,9 @@ "defaultMessage": "Following", "description": "src/components/UserProfile/index.tsx" }, + "ol0msv": { + "defaultMessage": "Copy TW FidO link" + }, "on+DYO": { "defaultMessage": "Confirm application" }, @@ -3168,6 +3307,13 @@ "orIq4X": { "defaultMessage": "More than 100 supports" }, + "p/Sz0T": { + "defaultMessage": "恢復留言", + "description": "src/components/Comment/Content/index.tsx" + }, + "p556q3": { + "defaultMessage": "Copied" + }, "p5qZnJ": { "defaultMessage": "liked", "description": "src/components/Notice/ArticleNotice/ArticleNewAppreciationNotice.tsx" @@ -3196,6 +3342,9 @@ "ptTHBL": { "defaultMessage": "Call-to-Support" }, + "pw6gGa": { + "defaultMessage": "ID number" + }, "pzTOmv": { "defaultMessage": "Followers" }, @@ -3308,6 +3457,10 @@ "defaultMessage": "Edit", "description": "src/components/Dialogs/ReviseArticleDialog/index.tsx" }, + "rW+2ID": { + "defaultMessage": "已由守望相助隊檢舉", + "description": "src/components/Comment/DropdownActions/CommunityWatchRemoveComment.tsx" + }, "rXnmeE": { "defaultMessage": "Confirm and Send" }, @@ -3352,6 +3505,9 @@ "sbhWw+": { "defaultMessage": "Schedule publication" }, + "scWFV3": { + "defaultMessage": "Spam advertising" + }, "sfj+KG": { "defaultMessage": "The results are mainly based on the records on the chain and will be synchronized to Matters later.", "description": "src/components/Forms/PaymentForm/Processing/index.tsx" @@ -3384,6 +3540,9 @@ "t2EpN/": { "defaultMessage": "Request on {network} network will be confirmed and synced to Matters in a bit" }, + "t2rFN5": { + "defaultMessage": "Create a signing ticket, open the TW FidO app, then return here for the proof input check." + }, "tBt9u0": { "defaultMessage": "Sign in", "description": "src/components/Forms/EmailLoginForm/index.tsx" @@ -3414,9 +3573,15 @@ "tWCQCd": { "defaultMessage": "The contract has sent {amount} USDT to the connected wallet" }, + "tYDhrI": { + "defaultMessage": "Once you request to join and are approved, the moments you post will appear in this channel." + }, "tZKvnZ": { "defaultMessage": "Unlike moment" }, + "tsUFZX": { + "defaultMessage": "分享到聯邦宇宙" + }, "tzq2+W": { "defaultMessage": "Send", "description": "src/views/Circle/Settings/ManageInvitation/AddInvitationDialog/PreSend.tsx" @@ -3425,6 +3590,10 @@ "defaultMessage": "My circle", "description": "src/views/Me/Settings/Notifications/Circle/index.tsx" }, + "u4QNpM": { + "defaultMessage": "恢復失敗,請稍後再試", + "description": "src/components/Comment/Content/index.tsx" + }, "u5aHb4": { "defaultMessage": "Copy Link" }, @@ -3517,8 +3686,9 @@ "defaultMessage": "Connect", "description": "src/components/Dialogs/BindEmailHintDialog/Content.tsx" }, - "vH8sCb": { - "defaultMessage": "Circle" + "vJQzbe": { + "defaultMessage": "已恢復留言", + "description": "src/components/Comment/Content/index.tsx" }, "vJd1we": { "defaultMessage": "Comment not found", @@ -3535,6 +3705,9 @@ "vX2bDy": { "defaultMessage": "Collect Article" }, + "vXgChH": { + "defaultMessage": "操作失敗,請稍後再試" + }, "va8Rnw": { "defaultMessage": "The author has closed the comment section" }, @@ -3652,10 +3825,6 @@ "defaultMessage": "This ID has been taken, please try another one", "description": "src/components/Dialogs/SetUserNameDialog/Content.tsx" }, - "x7kxvC": { - "defaultMessage": "Subscribe to unlock all articles of", - "description": "src/views/ArticleDetail/Wall/Circle/index.tsx" - }, "xDhqHi": { "defaultMessage": "Confirm", "description": "src/components/Forms/PaymentForm/PayTo/Confirm/index.tsx" @@ -3683,6 +3852,9 @@ "defaultMessage": "New discussions", "description": "src/views/Me/Settings/Notifications/Circle/index.tsx" }, + "xhfdqv": { + "defaultMessage": "Creating" + }, "xiV3Wz": { "defaultMessage": "More events", "description": "src/views/CampaignDetail/InfoHeader/OtherCampaigns/index.tsx" @@ -3768,10 +3940,16 @@ "z3uIHQ": { "defaultMessage": "Undo upvote" }, + "z5UXPc": { + "defaultMessage": "Apply to join" + }, "z91BKe": { "defaultMessage": "Archived Work", "description": "src/components/Notice/NoticeArticleTitle.tsx" }, + "z9jdNT": { + "defaultMessage": "Start browser proof" + }, "zAK5G+": { "defaultMessage": "The login link has been sent to {email}", "description": "src/components/Forms/Verification/LinkSent.tsx" diff --git a/lang/en.json b/lang/en.json index 8c8ff2c651..7b8296a987 100644 --- a/lang/en.json +++ b/lang/en.json @@ -9,10 +9,6 @@ "defaultMessage": "Please verify email first", "description": "src/views/Me/Settings/Settings/Password/index.tsx" }, - "+5O5ev": { - "defaultMessage": "\"{circleName}\"", - "description": "src/views/ArticleDetail/Wall/Circle/index.tsx" - }, "+5sU+5": { "defaultMessage": "Tort" }, @@ -32,6 +28,10 @@ "+GAaxB": { "defaultMessage": "The article has been archived due to violation of terms" }, + "+Gk0O1": { + "defaultMessage": "操作失敗,請稍後再試", + "description": "src/components/Comment/DropdownActions/CommunityWatchRemoveComment.tsx" + }, "+IYfVH": { "defaultMessage": "Remove from event featured" }, @@ -103,6 +103,9 @@ "/GyMKa": { "defaultMessage": "Share Article" }, + "/H/Ei0": { + "defaultMessage": "Copy report" + }, "/IMR+8": { "defaultMessage": "Top Supporters" }, @@ -165,6 +168,10 @@ "0PpH2v": { "defaultMessage": "Authorize in Wallet" }, + "0SLJni": { + "defaultMessage": "所有處理都會公開留痕", + "description": "src/components/Comment/DropdownActions/index.tsx" + }, "0SQatS": { "defaultMessage": "View all", "description": "src/views/CampaignDetail/SideParticipants/index.tsx" @@ -420,6 +427,10 @@ "4CrCbD": { "defaultMessage": "Community" }, + "4E1vEv": { + "defaultMessage": "色情廣告", + "description": "src/components/Comment/DropdownActions/CommunityWatchRemoveComment.tsx" + }, "4KuZ0o": { "defaultMessage": "Asset not found", "description": "ASSET_NOT_FOUND" @@ -479,6 +490,12 @@ "5XFd/5": { "defaultMessage": "Manage Circle" }, + "5YuSaZ": { + "defaultMessage": "After TW FidO signs, continue here on desktop, or copy the Mac proof link to a desktop browser." + }, + "5fjmnA": { + "defaultMessage": "碳基生物" + }, "5iii3x": { "defaultMessage": "Circle:", "description": "src/components/CircleDigest/UserProfile/index.tsx" @@ -595,6 +612,9 @@ "defaultMessage": "Edit", "description": "src/components/CircleComment/DropdownActions/EditButton.tsx" }, + "6wdAti": { + "defaultMessage": "No moments yet" + }, "73iajM": { "defaultMessage": "Oops. Something went wrong. Please try again later.", "description": "BAD_USER_INPUT" @@ -606,6 +626,9 @@ "defaultMessage": "Payout Canceled", "description": "src/components/Transaction/index.tsx" }, + "7DJ8wF": { + "defaultMessage": "Join the Moments channel" + }, "7HPPqs": { "defaultMessage": ". {SuggestButton}?" }, @@ -714,6 +737,9 @@ "defaultMessage": "Edit", "description": "src/components/CircleComment/DropdownActions/index.tsx" }, + "90M7k0": { + "defaultMessage": "馬特市守望相助隊" + }, "91AzwP": { "defaultMessage": "Only JPEG, PNG, and GIF and WebP images are supported." }, @@ -800,6 +826,9 @@ "defaultMessage": "Payment suspended or returned by card issuer when there are doubts about the transaction", "description": "src/components/Transaction/index.tsx" }, + "ABDljA": { + "defaultMessage": "Create ticket" + }, "AEiogR": { "defaultMessage": "This Google account is connected to a Matters account. Sign in to that account to disconnect it then try again", "description": "USER_SOCIAL_ACCOUNT_EXISTS" @@ -911,6 +940,9 @@ "C/4nS6": { "defaultMessage": "Connect and claim" }, + "C1yL+d": { + "defaultMessage": "TW FidO mobile flow" + }, "C3NKBg": { "defaultMessage": "Connected to {type}", "description": "src/views/Me/Settings/Settings/Socials/index.tsx" @@ -926,6 +958,9 @@ "defaultMessage": "bookmarked", "description": "src/components/Notice/ArticleNotice/ArticleNewSubscriberNotice.tsx" }, + "CFtbZu": { + "defaultMessage": "Run checks" + }, "CNMDU5": { "defaultMessage": "ID must be between {MIN_USER_NAME_LENGTH} and {MAX_USER_NAME_LENGTH} characters long" }, @@ -989,6 +1024,9 @@ "D9tEst": { "defaultMessage": "Matters Architect" }, + "DCgQVz": { + "defaultMessage": "PWA feasibility" + }, "DP3yqI": { "defaultMessage": "Donation count" }, @@ -999,6 +1037,9 @@ "DUvMii": { "defaultMessage": "Customize your call-to-support prompt to audience, or thank-you card for those who supported you." }, + "DW68ct": { + "defaultMessage": "Browser signals for the carbon based badge prover." + }, "DX0YH3": { "defaultMessage": "Wallet successfully bound", "description": "src/components/Forms/PaymentForm/BindWallet/index.tsx" @@ -1014,6 +1055,9 @@ "DjIpR6": { "defaultMessage": "Set Cover" }, + "DnOjES": { + "defaultMessage": "關閉" + }, "DoOt1q": { "defaultMessage": "Your channel suggestion has been accepted by the editor. Thank you for supporting Matters!", "description": "src/components/Notice/ArticleNotice/TopicChannelFeedbackAcceptedNotice.tsx" @@ -1036,6 +1080,9 @@ "DtO278": { "defaultMessage": "We’ve detected that several of your recent works have been recommended to related channels. They may not appear at the same time" }, + "Dx5Sas": { + "defaultMessage": "Copy Mac proof link" + }, "DyuHBH": { "defaultMessage": "Unpin from profile", "description": "src/components/CollectionDigest/DropdownActions/PinButton.tsx" @@ -1140,6 +1187,9 @@ "defaultMessage": "Switch to Optimism network now?", "description": "src/components/Forms/PaymentForm/SwitchNetwork/index.tsx" }, + "FXgOAP": { + "defaultMessage": "Browser handoff is ready. Browser proving is pending until the prover runs in a cross-origin isolated page." + }, "Fe682o": { "defaultMessage": "Next Month (Estimation)", "description": "src/views/Circle/Analytics/IncomeAnalytics/index.tsx" @@ -1164,6 +1214,9 @@ "defaultMessage": "This ID cannot be modified. Are you sure you want to use {id} as your Matters ID?", "description": "src/components/Dialogs/SetUserNameDialog/ConfirmStep.tsx" }, + "G/jeUL": { + "defaultMessage": "Open TW FidO" + }, "G/yZLu": { "defaultMessage": "Remove" }, @@ -1307,6 +1360,12 @@ "HzB4Lk": { "defaultMessage": "Tell readers why you edited this time..." }, + "I1eVEY": { + "defaultMessage": "Application submitted, pending review" + }, + "I3v/Va": { + "defaultMessage": "PWA proof handoff" + }, "IJ9YcQ": { "defaultMessage": "Unlink", "description": "src/components/Editor" @@ -1501,6 +1560,9 @@ "LWE7oq": { "defaultMessage": "Saving draft, are you sure you want to leave?" }, + "LXZyce": { + "defaultMessage": "You have already applied, please wait for the review." + }, "Lb0JsC": { "defaultMessage": "You have blocked that user" }, @@ -1564,6 +1626,9 @@ "defaultMessage": "Confirm in Wallet", "description": "src/components/Forms/PaymentForm/PayTo/Confirm/index.tsx" }, + "N73cBw": { + "defaultMessage": "WASM memory" + }, "NACY16": { "defaultMessage": "Why need to set up a wallet?", "description": "src/components/Forms/PaymentForm/BindWallet/index.tsx" @@ -1614,6 +1679,9 @@ "defaultMessage": "After deletion, the {commentType} will be removed immediately", "description": "src/components/CircleComment/DropdownActions/DeleteComment/Dialog.tsx" }, + "NloKwX": { + "defaultMessage": "Proof worker" + }, "NmhF45": { "defaultMessage": "Adding tags helps readers find your articles. Add or create new tags." }, @@ -1751,6 +1819,9 @@ "Q8Qw5B": { "defaultMessage": "Description" }, + "QHRze5": { + "defaultMessage": "Moments" + }, "QKJWqd": { "defaultMessage": "Bookmark", "description": "src/components/Buttons/TagBookmark/Bookmark.tsx" @@ -1826,6 +1897,9 @@ "defaultMessage": "Please confirm transaction password", "description": "src/components/Forms/PaymentForm/SetPassword/index.tsx" }, + "RYc4X0": { + "defaultMessage": "Browser proof" + }, "Rc4Oij": { "defaultMessage": "Firebolt" }, @@ -1911,6 +1985,9 @@ "Sj+TN8": { "defaultMessage": "Announcement" }, + "Su1LcW": { + "defaultMessage": "開啟後,新發佈作品預設可輸出到 Fediverse。" + }, "SuRTsQ": { "defaultMessage": "Register for ISCN" }, @@ -2008,6 +2085,9 @@ "USOHRK": { "defaultMessage": "Failed to edit, please try again." }, + "USq+mM": { + "defaultMessage": "Pornographic advertising" + }, "UUJzml": { "defaultMessage": "Logbook 2.0 has just launched. If you are an owner of Traveloggers, and haven't claimed, you may claim one from the new Logbook page:" }, @@ -2051,6 +2131,12 @@ "VBve8d": { "defaultMessage": "Settled in Matters" }, + "VDclc3": { + "defaultMessage": "You have joined the Moments channel. The moments you post will now appear in this channel." + }, + "VFQGq7": { + "defaultMessage": "Check result" + }, "VMQPwZ": { "defaultMessage": "Ended", "description": "src/views/Campaigns/Feeds/Tabs/index.tsx" @@ -2244,6 +2330,9 @@ "Y9IYvj": { "defaultMessage": "No suitable channels" }, + "YC2b3b": { + "defaultMessage": "Fediverse 聯邦發佈" + }, "YDMrKK": { "defaultMessage": "Users" }, @@ -2276,6 +2365,9 @@ "defaultMessage": "Archived for violation.", "description": "src/views/ArticleDetail/StickyTopBanner/index.tsx" }, + "ZAoAcG": { + "defaultMessage": "Threads, Mastodon, Misskey 這些地方的粉絲也會看到" + }, "ZAs170": { "defaultMessage": "Profile", "description": "src/views/Circle/Settings/index.tsx" @@ -2320,6 +2412,10 @@ "defaultMessage": "Number of readers: unique registered users plus number of anonymous IP addresses visited the article (Data will be updated periodically and may be delayed)", "description": "src/views/Me/Works/Published/SortTabs.tsx" }, + "a5ZQOK": { + "defaultMessage": "查看公開紀錄", + "description": "src/components/Comment/DropdownActions/CommunityWatchRemoveComment.tsx" + }, "aCTmEO": { "defaultMessage": "I don't have a wallet yet", "description": "src/components/Forms/SelectAuthMethodForm/WalletFeed.tsx" @@ -2368,6 +2464,9 @@ "defaultMessage": "go to the homepage", "description": "src/views/Callback/UI.tsx" }, + "atmn17": { + "defaultMessage": "Open isolated prover" + }, "awW+lk": { "defaultMessage": "Processing", "description": "src/components/Transaction/State/index.tsx" @@ -2424,6 +2523,9 @@ "defaultMessage": "pinned your comment in {commentArticle}", "description": "src/components/Notice/CommentNotice/CommentPinnedNotice.tsx" }, + "cB6zjC": { + "defaultMessage": "Signed TW FidO proof input is kept in this browser for the next proving step." + }, "cCpbBu": { "defaultMessage": "Popular Channel Authors" }, @@ -2502,6 +2604,10 @@ "d5bM8A": { "defaultMessage": "Select Date..." }, + "d95AX1": { + "defaultMessage": "Let me think about it", + "description": "src/views/HottestMoments/Apply/Dialog/index.tsx" + }, "dAPUJp": { "defaultMessage": "The dazzling light of a meteor shower is enough to illuminate the night sky. The Meteor Canoe badge signifies your participation in the Nomad Matters.", "description": "src/views/User/UserProfile/BadgeNomadLabel/index.tsx" @@ -2538,6 +2644,9 @@ "defaultMessage": "Comments and replies", "description": "src/views/Me/Settings/Notifications/GeneralSettings/index.tsx" }, + "dWt8c/": { + "defaultMessage": "Open the isolated browser container. It does not load the normal Matters bundle, so cross-origin isolation can be enabled for the zkID worker." + }, "dZlT9q": { "defaultMessage": "Your work has been recommended to the channels: {channelNames}. Are you satisfied with the result?" }, @@ -2574,6 +2683,10 @@ "eNv3Wm": { "defaultMessage": "Your work has been recommended to the channels: {channelNames}" }, + "eRTBgt": { + "defaultMessage": "本則貼文已由守望相助隊檢舉", + "description": "src/components/Comment/Content/index.tsx" + }, "eTpiYa": { "defaultMessage": "No data yet." }, @@ -2653,6 +2766,9 @@ "fWZYP5": { "defaultMessage": "Pinned" }, + "fbxogW": { + "defaultMessage": "Personhood" + }, "fko+MR": { "defaultMessage": "Retry claiming" }, @@ -2771,10 +2887,18 @@ "defaultMessage": "Insufficient: ", "description": "src/components/Balance/index.tsx" }, + "hdbKK1": { + "defaultMessage": "Moments", + "description": "src/components/Layout/SideChannelNav/index.tsx" + }, "hgtWIO": { "defaultMessage": "Articles have been collected", "description": "src/views/Me/Settings/Notifications/GeneralSettings/index.tsx" }, + "hifvI7": { + "defaultMessage": "Circle locked work", + "description": "src/views/ArticleDetail/Wall/Circle/index.tsx" + }, "hk2aiz": { "defaultMessage": "followed your circle", "description": "src/components/Notice/CircleNotice/CircleNewUserNotice.tsx" @@ -2912,10 +3036,6 @@ "kHMa3H": { "defaultMessage": "The new password and confirmation password do not match." }, - "kPylKK": { - "defaultMessage": "Circle", - "description": "src/views/Me/Settings/Notifications/index.tsx" - }, "kS3vTS": { "defaultMessage": "Liker ID", "description": "src/views/Me/Settings/Misc/LikerID.tsx" @@ -2931,6 +3051,9 @@ "defaultMessage": "Remove", "description": "src/components/Dialogs/RemoveArticleCollectionDialog/index.tsx" }, + "kqQp7b": { + "defaultMessage": "No signed proof input was found. Return to the TW FidO step and check the result again." + }, "ksIL/T": { "defaultMessage": "Kind reminder: This wallet address is different from the wallet address you use to log in to Matters" }, @@ -2980,6 +3103,9 @@ "lO7wKc": { "defaultMessage": "Not yet" }, + "lTleCS": { + "defaultMessage": "Checking" + }, "lYVn31": { "defaultMessage": "This work has been added to the schedule. Please go to the \"My Works\" page to confirm" }, @@ -3027,6 +3153,9 @@ "defaultMessage": "Hottest", "description": "src/views/Circle/Analytics/ContentAnalytics/index.tsx" }, + "mFn/Vv": { + "defaultMessage": "已更新 Fediverse 設定" + }, "mJEqC/": { "defaultMessage": "Go to {href}" }, @@ -3034,6 +3163,9 @@ "defaultMessage": "Remove bookmark", "description": "src/components/Buttons/TagBookmark/Unbookmark.tsx" }, + "mMji02": { + "defaultMessage": "開啟" + }, "mPe6DK": { "defaultMessage": "subscribed your circle", "description": "src/components/Notice/CircleNotice/CircleNewUserNotice.tsx" @@ -3137,6 +3269,10 @@ "defaultMessage": "Invalid Email", "description": "USER_EMAIL_INVALID" }, + "oA2Pur": { + "defaultMessage": "濫發廣告", + "description": "src/components/Comment/DropdownActions/CommunityWatchRemoveComment.tsx" + }, "oEHAIT": { "defaultMessage": "Cancel schedule", "description": "confirm cancel schedule button" @@ -3159,6 +3295,9 @@ "defaultMessage": "Following", "description": "src/components/UserProfile/index.tsx" }, + "ol0msv": { + "defaultMessage": "Copy TW FidO link" + }, "on+DYO": { "defaultMessage": "Confirm application" }, @@ -3168,6 +3307,13 @@ "orIq4X": { "defaultMessage": "More than 100 supports" }, + "p/Sz0T": { + "defaultMessage": "Restore comment", + "description": "src/components/Comment/Content/index.tsx" + }, + "p556q3": { + "defaultMessage": "Copied" + }, "p5qZnJ": { "defaultMessage": "liked", "description": "src/components/Notice/ArticleNotice/ArticleNewAppreciationNotice.tsx" @@ -3196,6 +3342,9 @@ "ptTHBL": { "defaultMessage": "Call-to-Support" }, + "pw6gGa": { + "defaultMessage": "ID number" + }, "pzTOmv": { "defaultMessage": "Followers" }, @@ -3308,6 +3457,10 @@ "defaultMessage": "Edit", "description": "src/components/Dialogs/ReviseArticleDialog/index.tsx" }, + "rW+2ID": { + "defaultMessage": "已由守望相助隊檢舉", + "description": "src/components/Comment/DropdownActions/CommunityWatchRemoveComment.tsx" + }, "rXnmeE": { "defaultMessage": "Confirm and Send" }, @@ -3352,6 +3505,9 @@ "sbhWw+": { "defaultMessage": "Schedule publication" }, + "scWFV3": { + "defaultMessage": "Spam advertising" + }, "sfj+KG": { "defaultMessage": "The results are mainly based on the records on the chain and will be synchronized to Matters later.", "description": "src/components/Forms/PaymentForm/Processing/index.tsx" @@ -3384,6 +3540,9 @@ "t2EpN/": { "defaultMessage": "Request on {network} network will be confirmed and synced to Matters in a bit" }, + "t2rFN5": { + "defaultMessage": "Create a signing ticket, open the TW FidO app, then return here for the proof input check." + }, "tBt9u0": { "defaultMessage": "Sign in", "description": "src/components/Forms/EmailLoginForm/index.tsx" @@ -3414,9 +3573,15 @@ "tWCQCd": { "defaultMessage": "The contract has sent {amount} USDT to the connected wallet" }, + "tYDhrI": { + "defaultMessage": "Once you request to join and are approved, the moments you post will appear in this channel." + }, "tZKvnZ": { "defaultMessage": "Unlike moment" }, + "tsUFZX": { + "defaultMessage": "分享到聯邦宇宙" + }, "tzq2+W": { "defaultMessage": "Send", "description": "src/views/Circle/Settings/ManageInvitation/AddInvitationDialog/PreSend.tsx" @@ -3425,6 +3590,10 @@ "defaultMessage": "My circle", "description": "src/views/Me/Settings/Notifications/Circle/index.tsx" }, + "u4QNpM": { + "defaultMessage": "Failed to restore comment. Please try again later.", + "description": "src/components/Comment/Content/index.tsx" + }, "u5aHb4": { "defaultMessage": "Copy Link" }, @@ -3517,8 +3686,9 @@ "defaultMessage": "Connect", "description": "src/components/Dialogs/BindEmailHintDialog/Content.tsx" }, - "vH8sCb": { - "defaultMessage": "Circle" + "vJQzbe": { + "defaultMessage": "Comment restored", + "description": "src/components/Comment/Content/index.tsx" }, "vJd1we": { "defaultMessage": "Comment not found", @@ -3535,6 +3705,9 @@ "vX2bDy": { "defaultMessage": "Collect Article" }, + "vXgChH": { + "defaultMessage": "操作失敗,請稍後再試" + }, "va8Rnw": { "defaultMessage": "The author has closed the comment section" }, @@ -3652,10 +3825,6 @@ "defaultMessage": "This ID has been taken, please try another one", "description": "src/components/Dialogs/SetUserNameDialog/Content.tsx" }, - "x7kxvC": { - "defaultMessage": "Subscribe to unlock all articles of", - "description": "src/views/ArticleDetail/Wall/Circle/index.tsx" - }, "xDhqHi": { "defaultMessage": "Confirm", "description": "src/components/Forms/PaymentForm/PayTo/Confirm/index.tsx" @@ -3683,6 +3852,9 @@ "defaultMessage": "New discussions", "description": "src/views/Me/Settings/Notifications/Circle/index.tsx" }, + "xhfdqv": { + "defaultMessage": "Creating" + }, "xiV3Wz": { "defaultMessage": "More events", "description": "src/views/CampaignDetail/InfoHeader/OtherCampaigns/index.tsx" @@ -3768,10 +3940,16 @@ "z3uIHQ": { "defaultMessage": "Undo upvote" }, + "z5UXPc": { + "defaultMessage": "Apply to join" + }, "z91BKe": { "defaultMessage": "Archived Work", "description": "src/components/Notice/NoticeArticleTitle.tsx" }, + "z9jdNT": { + "defaultMessage": "Start browser proof" + }, "zAK5G+": { "defaultMessage": "The login link has been sent to {email}", "description": "src/components/Forms/Verification/LinkSent.tsx" diff --git a/lang/zh-Hans.json b/lang/zh-Hans.json index 7df45d6c0d..842476d4c7 100644 --- a/lang/zh-Hans.json +++ b/lang/zh-Hans.json @@ -9,10 +9,6 @@ "defaultMessage": "请验证邮箱", "description": "src/views/Me/Settings/Settings/Password/index.tsx" }, - "+5O5ev": { - "defaultMessage": "「{circleName}」所有作品", - "description": "src/views/ArticleDetail/Wall/Circle/index.tsx" - }, "+5sU+5": { "defaultMessage": "侵权" }, @@ -32,6 +28,10 @@ "+GAaxB": { "defaultMessage": "作品因违反社区约章被封存" }, + "+Gk0O1": { + "defaultMessage": "操作失敗,請稍後再試", + "description": "src/components/Comment/DropdownActions/CommunityWatchRemoveComment.tsx" + }, "+IYfVH": { "defaultMessage": "移除活动精选" }, @@ -103,6 +103,9 @@ "/GyMKa": { "defaultMessage": "分享作品" }, + "/H/Ei0": { + "defaultMessage": "Copy report" + }, "/IMR+8": { "defaultMessage": "支持排行榜" }, @@ -165,6 +168,10 @@ "0PpH2v": { "defaultMessage": "去钱包授权" }, + "0SLJni": { + "defaultMessage": "所有處理都會公開留痕", + "description": "src/components/Comment/DropdownActions/index.tsx" + }, "0SQatS": { "defaultMessage": "看全部", "description": "src/views/CampaignDetail/SideParticipants/index.tsx" @@ -420,6 +427,10 @@ "4CrCbD": { "defaultMessage": "自治" }, + "4E1vEv": { + "defaultMessage": "色情廣告", + "description": "src/components/Comment/DropdownActions/CommunityWatchRemoveComment.tsx" + }, "4KuZ0o": { "defaultMessage": "资源不存在", "description": "ASSET_NOT_FOUND" @@ -479,6 +490,12 @@ "5XFd/5": { "defaultMessage": "管理围炉" }, + "5YuSaZ": { + "defaultMessage": "After TW FidO signs, continue here on desktop, or copy the Mac proof link to a desktop browser." + }, + "5fjmnA": { + "defaultMessage": "碳基生物" + }, "5iii3x": { "defaultMessage": "围炉:", "description": "src/components/CircleDigest/UserProfile/index.tsx" @@ -595,6 +612,9 @@ "defaultMessage": "编辑评论", "description": "src/components/CircleComment/DropdownActions/EditButton.tsx" }, + "6wdAti": { + "defaultMessage": "还没有动态" + }, "73iajM": { "defaultMessage": "出错了,请检查你输入的内容", "description": "BAD_USER_INPUT" @@ -606,6 +626,9 @@ "defaultMessage": "提现撤销", "description": "src/components/Transaction/index.tsx" }, + "7DJ8wF": { + "defaultMessage": "加入闲聊频道" + }, "7HPPqs": { "defaultMessage": "。{SuggestButton}?" }, @@ -714,6 +737,9 @@ "defaultMessage": "编辑评论", "description": "src/components/CircleComment/DropdownActions/index.tsx" }, + "90M7k0": { + "defaultMessage": "馬特市守望相助隊" + }, "91AzwP": { "defaultMessage": "仅支持 JPEG、PNG、GIF 和 WebP 图片" }, @@ -800,6 +826,9 @@ "defaultMessage": "信用卡交易内容有疑虑时,发卡机构暂停或退回的款项", "description": "src/components/Transaction/index.tsx" }, + "ABDljA": { + "defaultMessage": "Create ticket" + }, "AEiogR": { "defaultMessage": "该 Google 账号已关联至其他 Matters 账号,请登录该账号解绑后再试", "description": "USER_SOCIAL_ACCOUNT_EXISTS" @@ -911,6 +940,9 @@ "C/4nS6": { "defaultMessage": "绑定并提领" }, + "C1yL+d": { + "defaultMessage": "TW FidO mobile flow" + }, "C3NKBg": { "defaultMessage": "{type} 已绑定", "description": "src/views/Me/Settings/Settings/Socials/index.tsx" @@ -926,6 +958,9 @@ "defaultMessage": "收藏了", "description": "src/components/Notice/ArticleNotice/ArticleNewSubscriberNotice.tsx" }, + "CFtbZu": { + "defaultMessage": "Run checks" + }, "CNMDU5": { "defaultMessage": "ID 字符数须介于 {MIN_USER_NAME_LENGTH} 到 {MAX_USER_NAME_LENGTH} 之间" }, @@ -989,6 +1024,9 @@ "D9tEst": { "defaultMessage": "马特市建筑师" }, + "DCgQVz": { + "defaultMessage": "PWA feasibility" + }, "DP3yqI": { "defaultMessage": "支持人数" }, @@ -999,6 +1037,9 @@ "DUvMii": { "defaultMessage": "可自订号召支持的内容,以及收到支持后的感谢文字" }, + "DW68ct": { + "defaultMessage": "Browser signals for the carbon based badge prover." + }, "DX0YH3": { "defaultMessage": "已成功绑定钱包", "description": "src/components/Forms/PaymentForm/BindWallet/index.tsx" @@ -1014,6 +1055,9 @@ "DjIpR6": { "defaultMessage": "选择封面" }, + "DnOjES": { + "defaultMessage": "關閉" + }, "DoOt1q": { "defaultMessage": "你的频道建议已被编辑采纳,感谢你对 Matters 的支持~", "description": "src/components/Notice/ArticleNotice/TopicChannelFeedbackAcceptedNotice.tsx" @@ -1036,6 +1080,9 @@ "DtO278": { "defaultMessage": "检测到近期你的多篇文章被推荐到相关频道,他们有可能不会同时出现" }, + "Dx5Sas": { + "defaultMessage": "Copy Mac proof link" + }, "DyuHBH": { "defaultMessage": "取消代表作", "description": "src/components/CollectionDigest/DropdownActions/PinButton.tsx" @@ -1140,6 +1187,9 @@ "defaultMessage": "目前非 Optimism 网络,立即切换?", "description": "src/components/Forms/PaymentForm/SwitchNetwork/index.tsx" }, + "FXgOAP": { + "defaultMessage": "Browser handoff is ready. Browser proving is pending until the prover runs in a cross-origin isolated page." + }, "Fe682o": { "defaultMessage": "下月预期营收", "description": "src/views/Circle/Analytics/IncomeAnalytics/index.tsx" @@ -1164,6 +1214,9 @@ "defaultMessage": "ID 设置后无法修改,确认使用 {id} 作为 Matters ID 吗?", "description": "src/components/Dialogs/SetUserNameDialog/ConfirmStep.tsx" }, + "G/jeUL": { + "defaultMessage": "Open TW FidO" + }, "G/yZLu": { "defaultMessage": "移除" }, @@ -1307,6 +1360,12 @@ "HzB4Lk": { "defaultMessage": "告知读者你此次编辑的更动有哪些⋯" }, + "I1eVEY": { + "defaultMessage": "已送出申请,待审核" + }, + "I3v/Va": { + "defaultMessage": "PWA proof handoff" + }, "IJ9YcQ": { "defaultMessage": "取消链接", "description": "src/components/Editor" @@ -1501,6 +1560,9 @@ "LWE7oq": { "defaultMessage": "草稿保存中,确定要离开吗?" }, + "LXZyce": { + "defaultMessage": "你已经申请过了,请耐心等候审核" + }, "Lb0JsC": { "defaultMessage": "你屏蔽了该用户" }, @@ -1564,6 +1626,9 @@ "defaultMessage": "去钱包确认", "description": "src/components/Forms/PaymentForm/PayTo/Confirm/index.tsx" }, + "N73cBw": { + "defaultMessage": "WASM memory" + }, "NACY16": { "defaultMessage": "为什么需要设定钱包 ?", "description": "src/components/Forms/PaymentForm/BindWallet/index.tsx" @@ -1614,6 +1679,9 @@ "defaultMessage": "确认删除后,{commentType}会立即消失。", "description": "src/components/CircleComment/DropdownActions/DeleteComment/Dialog.tsx" }, + "NloKwX": { + "defaultMessage": "Proof worker" + }, "NmhF45": { "defaultMessage": "通过添加标签帮助读者更好地找到你的作品。如果没有合适的标签,你可以创建新的。" }, @@ -1751,6 +1819,9 @@ "Q8Qw5B": { "defaultMessage": "描述" }, + "QHRze5": { + "defaultMessage": "闲聊" + }, "QKJWqd": { "defaultMessage": "收藏", "description": "src/components/Buttons/TagBookmark/Bookmark.tsx" @@ -1826,6 +1897,9 @@ "defaultMessage": "请再次输入交易密码", "description": "src/components/Forms/PaymentForm/SetPassword/index.tsx" }, + "RYc4X0": { + "defaultMessage": "Browser proof" + }, "Rc4Oij": { "defaultMessage": "火闪电" }, @@ -1911,6 +1985,9 @@ "Sj+TN8": { "defaultMessage": "公告" }, + "Su1LcW": { + "defaultMessage": "開啟後,新發佈作品預設可輸出到 Fediverse。" + }, "SuRTsQ": { "defaultMessage": "注册 ISCN" }, @@ -2008,6 +2085,9 @@ "USOHRK": { "defaultMessage": "修改失败,请稍候重试" }, + "USq+mM": { + "defaultMessage": "Pornographic advertising" + }, "UUJzml": { "defaultMessage": "Logbook 2.0 刚刚推出。如果你是 Traveloggers 的所有者,且尚未领取,你可以从新的日志页面领取:" }, @@ -2051,6 +2131,12 @@ "VBve8d": { "defaultMessage": "搬家到 Matters" }, + "VDclc3": { + "defaultMessage": "你已加入闲聊频道,即日起发出的闲聊将显示于此频道" + }, + "VFQGq7": { + "defaultMessage": "Check result" + }, "VMQPwZ": { "defaultMessage": "过往活动", "description": "src/views/Campaigns/Feeds/Tabs/index.tsx" @@ -2244,6 +2330,9 @@ "Y9IYvj": { "defaultMessage": "没有适合的频道" }, + "YC2b3b": { + "defaultMessage": "Fediverse 聯邦發佈" + }, "YDMrKK": { "defaultMessage": "用户" }, @@ -2276,6 +2365,9 @@ "defaultMessage": "因违反用户协定而被封存,", "description": "src/views/ArticleDetail/StickyTopBanner/index.tsx" }, + "ZAoAcG": { + "defaultMessage": "Threads, Mastodon, Misskey 這些地方的粉絲也會看到" + }, "ZAs170": { "defaultMessage": "基本资料", "description": "src/views/Circle/Settings/index.tsx" @@ -2320,6 +2412,10 @@ "defaultMessage": "读者数量:访问过作品页的不重复的登录用户数加匿名 IP 数(数据周期更新,可能存在延迟)", "description": "src/views/Me/Works/Published/SortTabs.tsx" }, + "a5ZQOK": { + "defaultMessage": "查看公開紀錄", + "description": "src/components/Comment/DropdownActions/CommunityWatchRemoveComment.tsx" + }, "aCTmEO": { "defaultMessage": "我还没有钱包", "description": "src/components/Forms/SelectAuthMethodForm/WalletFeed.tsx" @@ -2368,6 +2464,9 @@ "defaultMessage": "前往首页", "description": "src/views/Callback/UI.tsx" }, + "atmn17": { + "defaultMessage": "Open isolated prover" + }, "awW+lk": { "defaultMessage": "进行中…", "description": "src/components/Transaction/State/index.tsx" @@ -2424,6 +2523,9 @@ "defaultMessage": "将你的评论在 {commentArticle} 中置顶", "description": "src/components/Notice/CommentNotice/CommentPinnedNotice.tsx" }, + "cB6zjC": { + "defaultMessage": "Signed TW FidO proof input is kept in this browser for the next proving step." + }, "cCpbBu": { "defaultMessage": "频道热门作者" }, @@ -2502,6 +2604,10 @@ "d5bM8A": { "defaultMessage": "投稿日程⋯" }, + "d95AX1": { + "defaultMessage": "我再想想", + "description": "src/views/HottestMoments/Apply/Dialog/index.tsx" + }, "dAPUJp": { "defaultMessage": "流星雨的绚烂光芒足以点亮夜空。流星号徽章纪念你曾参与「游牧者计划」。", "description": "src/views/User/UserProfile/BadgeNomadLabel/index.tsx" @@ -2538,6 +2644,9 @@ "defaultMessage": "评论和回复", "description": "src/views/Me/Settings/Notifications/GeneralSettings/index.tsx" }, + "dWt8c/": { + "defaultMessage": "Open the isolated browser container. It does not load the normal Matters bundle, so cross-origin isolation can be enabled for the zkID worker." + }, "dZlT9q": { "defaultMessage": "已推荐你的这篇作品到频道:{channelNames},你对结果满意吗?" }, @@ -2574,6 +2683,10 @@ "eNv3Wm": { "defaultMessage": "已推荐你的这篇作品到频道:{channelNames}" }, + "eRTBgt": { + "defaultMessage": "本則貼文已由守望相助隊檢舉", + "description": "src/components/Comment/Content/index.tsx" + }, "eTpiYa": { "defaultMessage": "尚无支持数据" }, @@ -2653,6 +2766,9 @@ "fWZYP5": { "defaultMessage": "置顶" }, + "fbxogW": { + "defaultMessage": "Personhood" + }, "fko+MR": { "defaultMessage": "重试提领" }, @@ -2771,10 +2887,18 @@ "defaultMessage": "余额不足:", "description": "src/components/Balance/index.tsx" }, + "hdbKK1": { + "defaultMessage": "闲聊", + "description": "src/components/Layout/SideChannelNav/index.tsx" + }, "hgtWIO": { "defaultMessage": "作品被关联", "description": "src/views/Me/Settings/Notifications/GeneralSettings/index.tsx" }, + "hifvI7": { + "defaultMessage": "围炉上锁作品", + "description": "src/views/ArticleDetail/Wall/Circle/index.tsx" + }, "hk2aiz": { "defaultMessage": "关注了你的围炉", "description": "src/components/Notice/CircleNotice/CircleNewUserNotice.tsx" @@ -2912,10 +3036,6 @@ "kHMa3H": { "defaultMessage": "密码不一致" }, - "kPylKK": { - "defaultMessage": "围炉", - "description": "src/views/Me/Settings/Notifications/index.tsx" - }, "kS3vTS": { "defaultMessage": "Liker ID", "description": "src/views/Me/Settings/Misc/LikerID.tsx" @@ -2931,6 +3051,9 @@ "defaultMessage": "确认移出", "description": "src/components/Dialogs/RemoveArticleCollectionDialog/index.tsx" }, + "kqQp7b": { + "defaultMessage": "No signed proof input was found. Return to the TW FidO step and check the result again." + }, "ksIL/T": { "defaultMessage": "贴心提醒:此钱包地址不同于您用于登录 Matters 的钱包地址" }, @@ -2980,6 +3103,9 @@ "lO7wKc": { "defaultMessage": "再想想" }, + "lTleCS": { + "defaultMessage": "Checking" + }, "lYVn31": { "defaultMessage": "此作品已加入定时发布,请前往「我的创作」页确认" }, @@ -3027,6 +3153,9 @@ "defaultMessage": "站内阅读热门排行", "description": "src/views/Circle/Analytics/ContentAnalytics/index.tsx" }, + "mFn/Vv": { + "defaultMessage": "已更新 Fediverse 設定" + }, "mJEqC/": { "defaultMessage": "跳转至 {href}" }, @@ -3034,6 +3163,9 @@ "defaultMessage": "取消收藏", "description": "src/components/Buttons/TagBookmark/Unbookmark.tsx" }, + "mMji02": { + "defaultMessage": "開啟" + }, "mPe6DK": { "defaultMessage": "订阅了你的围炉", "description": "src/components/Notice/CircleNotice/CircleNewUserNotice.tsx" @@ -3137,6 +3269,10 @@ "defaultMessage": "邮箱不正确", "description": "USER_EMAIL_INVALID" }, + "oA2Pur": { + "defaultMessage": "濫發廣告", + "description": "src/components/Comment/DropdownActions/CommunityWatchRemoveComment.tsx" + }, "oEHAIT": { "defaultMessage": "取消发布", "description": "confirm cancel schedule button" @@ -3159,6 +3295,9 @@ "defaultMessage": "关注中", "description": "src/components/UserProfile/index.tsx" }, + "ol0msv": { + "defaultMessage": "Copy TW FidO link" + }, "on+DYO": { "defaultMessage": "确认报名" }, @@ -3168,6 +3307,13 @@ "orIq4X": { "defaultMessage": "支持超过 100 次" }, + "p/Sz0T": { + "defaultMessage": "恢复留言", + "description": "src/components/Comment/Content/index.tsx" + }, + "p556q3": { + "defaultMessage": "Copied" + }, "p5qZnJ": { "defaultMessage": "赞赏了", "description": "src/components/Notice/ArticleNotice/ArticleNewAppreciationNotice.tsx" @@ -3196,6 +3342,9 @@ "ptTHBL": { "defaultMessage": "支持号召" }, + "pw6gGa": { + "defaultMessage": "ID number" + }, "pzTOmv": { "defaultMessage": "关注者" }, @@ -3308,6 +3457,10 @@ "defaultMessage": "开始修改", "description": "src/components/Dialogs/ReviseArticleDialog/index.tsx" }, + "rW+2ID": { + "defaultMessage": "已由守望相助隊檢舉", + "description": "src/components/Comment/DropdownActions/CommunityWatchRemoveComment.tsx" + }, "rXnmeE": { "defaultMessage": "确认发送" }, @@ -3352,6 +3505,9 @@ "sbhWw+": { "defaultMessage": "定时发布" }, + "scWFV3": { + "defaultMessage": "Spam advertising" + }, "sfj+KG": { "defaultMessage": "结果以链上记录为主,稍后同步至 Matters", "description": "src/components/Forms/PaymentForm/Processing/index.tsx" @@ -3384,6 +3540,9 @@ "t2EpN/": { "defaultMessage": "持续与 {network} 网络同步,稍后更新至 Matters" }, + "t2rFN5": { + "defaultMessage": "Create a signing ticket, open the TW FidO app, then return here for the proof input check." + }, "tBt9u0": { "defaultMessage": "登录", "description": "src/components/Forms/EmailLoginForm/index.tsx" @@ -3414,9 +3573,15 @@ "tWCQCd": { "defaultMessage": "交易合約已将 {amount} USDT 发送至绑定钱包" }, + "tYDhrI": { + "defaultMessage": "申请通过之后,个人发出的动态将显示于此频道" + }, "tZKvnZ": { "defaultMessage": "取消喜欢动态" }, + "tsUFZX": { + "defaultMessage": "分享到聯邦宇宙" + }, "tzq2+W": { "defaultMessage": "寄出邀请", "description": "src/views/Circle/Settings/ManageInvitation/AddInvitationDialog/PreSend.tsx" @@ -3425,6 +3590,10 @@ "defaultMessage": "我的围炉", "description": "src/views/Me/Settings/Notifications/Circle/index.tsx" }, + "u4QNpM": { + "defaultMessage": "恢复失败,请稍后再试", + "description": "src/components/Comment/Content/index.tsx" + }, "u5aHb4": { "defaultMessage": "复制链接" }, @@ -3517,8 +3686,9 @@ "defaultMessage": "去绑定", "description": "src/components/Dialogs/BindEmailHintDialog/Content.tsx" }, - "vH8sCb": { - "defaultMessage": "我的围炉" + "vJQzbe": { + "defaultMessage": "已恢复留言", + "description": "src/components/Comment/Content/index.tsx" }, "vJd1we": { "defaultMessage": "评论不存在", @@ -3535,6 +3705,9 @@ "vX2bDy": { "defaultMessage": "关联作品" }, + "vXgChH": { + "defaultMessage": "操作失敗,請稍後再試" + }, "va8Rnw": { "defaultMessage": "作者已关闭评论区" }, @@ -3652,10 +3825,6 @@ "defaultMessage": "ID 已被使用,请修改后再试", "description": "src/components/Dialogs/SetUserNameDialog/Content.tsx" }, - "x7kxvC": { - "defaultMessage": "订阅解锁", - "description": "src/views/ArticleDetail/Wall/Circle/index.tsx" - }, "xDhqHi": { "defaultMessage": "确认送出", "description": "src/components/Forms/PaymentForm/PayTo/Confirm/index.tsx" @@ -3683,6 +3852,9 @@ "defaultMessage": "留言", "description": "src/views/Me/Settings/Notifications/Circle/index.tsx" }, + "xhfdqv": { + "defaultMessage": "Creating" + }, "xiV3Wz": { "defaultMessage": "更多活动", "description": "src/views/CampaignDetail/InfoHeader/OtherCampaigns/index.tsx" @@ -3768,10 +3940,16 @@ "z3uIHQ": { "defaultMessage": "取消点赞" }, + "z5UXPc": { + "defaultMessage": "申请加入" + }, "z91BKe": { "defaultMessage": "已归档作品", "description": "src/components/Notice/NoticeArticleTitle.tsx" }, + "z9jdNT": { + "defaultMessage": "Start browser proof" + }, "zAK5G+": { "defaultMessage": "登录链接已发送至 {email}", "description": "src/components/Forms/Verification/LinkSent.tsx" diff --git a/lang/zh-Hant.json b/lang/zh-Hant.json index 3a7a628b4a..91a22de82e 100644 --- a/lang/zh-Hant.json +++ b/lang/zh-Hant.json @@ -9,10 +9,6 @@ "defaultMessage": "請驗證電子郵件地址", "description": "src/views/Me/Settings/Settings/Password/index.tsx" }, - "+5O5ev": { - "defaultMessage": "「{circleName}」所有作品", - "description": "src/views/ArticleDetail/Wall/Circle/index.tsx" - }, "+5sU+5": { "defaultMessage": "侵權" }, @@ -32,6 +28,10 @@ "+GAaxB": { "defaultMessage": "作品因違反社區約章被封存" }, + "+Gk0O1": { + "defaultMessage": "操作失敗,請稍後再試", + "description": "src/components/Comment/DropdownActions/CommunityWatchRemoveComment.tsx" + }, "+IYfVH": { "defaultMessage": "移除活動精選" }, @@ -103,6 +103,9 @@ "/GyMKa": { "defaultMessage": "分享作品" }, + "/H/Ei0": { + "defaultMessage": "Copy report" + }, "/IMR+8": { "defaultMessage": "支持排行榜" }, @@ -165,6 +168,10 @@ "0PpH2v": { "defaultMessage": "去錢包授權" }, + "0SLJni": { + "defaultMessage": "所有處理都會公開留痕", + "description": "src/components/Comment/DropdownActions/index.tsx" + }, "0SQatS": { "defaultMessage": "看全部", "description": "src/views/CampaignDetail/SideParticipants/index.tsx" @@ -420,6 +427,10 @@ "4CrCbD": { "defaultMessage": "自治" }, + "4E1vEv": { + "defaultMessage": "色情廣告", + "description": "src/components/Comment/DropdownActions/CommunityWatchRemoveComment.tsx" + }, "4KuZ0o": { "defaultMessage": "資源不存在", "description": "ASSET_NOT_FOUND" @@ -479,6 +490,12 @@ "5XFd/5": { "defaultMessage": "管理圍爐" }, + "5YuSaZ": { + "defaultMessage": "After TW FidO signs, continue here on desktop, or copy the Mac proof link to a desktop browser." + }, + "5fjmnA": { + "defaultMessage": "碳基生物" + }, "5iii3x": { "defaultMessage": "圍爐:", "description": "src/components/CircleDigest/UserProfile/index.tsx" @@ -595,6 +612,9 @@ "defaultMessage": "編輯", "description": "src/components/CircleComment/DropdownActions/EditButton.tsx" }, + "6wdAti": { + "defaultMessage": "還沒有動態" + }, "73iajM": { "defaultMessage": "出錯了,請檢查你輸入的內容", "description": "BAD_USER_INPUT" @@ -606,6 +626,9 @@ "defaultMessage": "提現撤銷", "description": "src/components/Transaction/index.tsx" }, + "7DJ8wF": { + "defaultMessage": "加入閒聊頻道" + }, "7HPPqs": { "defaultMessage": "。{SuggestButton}?" }, @@ -714,6 +737,9 @@ "defaultMessage": "編輯", "description": "src/components/CircleComment/DropdownActions/index.tsx" }, + "90M7k0": { + "defaultMessage": "馬特市守望相助隊" + }, "91AzwP": { "defaultMessage": "僅支持 JPEG、PNG、GIF 和 WebP 圖片" }, @@ -800,6 +826,9 @@ "defaultMessage": "信用卡交易內容有疑慮時,發卡機構暫停或退回的款項", "description": "src/components/Transaction/index.tsx" }, + "ABDljA": { + "defaultMessage": "Create ticket" + }, "AEiogR": { "defaultMessage": "該 Google 帳號已關聯至其他 Matters 帳號,請登入該帳號解綁後再試", "description": "USER_SOCIAL_ACCOUNT_EXISTS" @@ -911,6 +940,9 @@ "C/4nS6": { "defaultMessage": "綁定並提領" }, + "C1yL+d": { + "defaultMessage": "TW FidO mobile flow" + }, "C3NKBg": { "defaultMessage": "{type} 已綁定", "description": "src/views/Me/Settings/Settings/Socials/index.tsx" @@ -926,6 +958,9 @@ "defaultMessage": "收藏了", "description": "src/components/Notice/ArticleNotice/ArticleNewSubscriberNotice.tsx" }, + "CFtbZu": { + "defaultMessage": "Run checks" + }, "CNMDU5": { "defaultMessage": "字符數須介於 {MIN_USER_NAME_LENGTH} 到 {MAX_USER_NAME_LENGTH} 之間" }, @@ -989,6 +1024,9 @@ "D9tEst": { "defaultMessage": "馬特市建築師" }, + "DCgQVz": { + "defaultMessage": "PWA feasibility" + }, "DP3yqI": { "defaultMessage": "支持人數" }, @@ -999,6 +1037,9 @@ "DUvMii": { "defaultMessage": "可自訂號召支持的內容,以及收到支持後的感謝文字" }, + "DW68ct": { + "defaultMessage": "Browser signals for the carbon based badge prover." + }, "DX0YH3": { "defaultMessage": "已成功綁定錢包", "description": "src/components/Forms/PaymentForm/BindWallet/index.tsx" @@ -1014,6 +1055,9 @@ "DjIpR6": { "defaultMessage": "選擇封面" }, + "DnOjES": { + "defaultMessage": "關閉" + }, "DoOt1q": { "defaultMessage": "你的頻道建議已被編輯採納,感謝你對 Matters 的支持~", "description": "src/components/Notice/ArticleNotice/TopicChannelFeedbackAcceptedNotice.tsx" @@ -1036,6 +1080,9 @@ "DtO278": { "defaultMessage": "檢測到近期你的多篇文章被推薦到相關頻道,它們有可能不會同時出現" }, + "Dx5Sas": { + "defaultMessage": "Copy Mac proof link" + }, "DyuHBH": { "defaultMessage": "取消代表作", "description": "src/components/CollectionDigest/DropdownActions/PinButton.tsx" @@ -1140,6 +1187,9 @@ "defaultMessage": "目前非 Optimism 網路,立即切換?", "description": "src/components/Forms/PaymentForm/SwitchNetwork/index.tsx" }, + "FXgOAP": { + "defaultMessage": "Browser handoff is ready. Browser proving is pending until the prover runs in a cross-origin isolated page." + }, "Fe682o": { "defaultMessage": "下月預期營收", "description": "src/views/Circle/Analytics/IncomeAnalytics/index.tsx" @@ -1164,6 +1214,9 @@ "defaultMessage": "ID 設置後無法修改,確認使用 {id} 作為 Matters ID 嗎?", "description": "src/components/Dialogs/SetUserNameDialog/ConfirmStep.tsx" }, + "G/jeUL": { + "defaultMessage": "Open TW FidO" + }, "G/yZLu": { "defaultMessage": "移除" }, @@ -1307,6 +1360,12 @@ "HzB4Lk": { "defaultMessage": "告知讀者你此次編輯的更動有哪些⋯" }, + "I1eVEY": { + "defaultMessage": "已送出申請,待審核" + }, + "I3v/Va": { + "defaultMessage": "PWA proof handoff" + }, "IJ9YcQ": { "defaultMessage": "取消連結", "description": "src/components/Editor" @@ -1501,6 +1560,9 @@ "LWE7oq": { "defaultMessage": "草稿儲存中,確定要離開嗎?" }, + "LXZyce": { + "defaultMessage": "你已經申請過了,請耐心等候審核" + }, "Lb0JsC": { "defaultMessage": "你封鎖了該用戶" }, @@ -1564,6 +1626,9 @@ "defaultMessage": "去錢包確認", "description": "src/components/Forms/PaymentForm/PayTo/Confirm/index.tsx" }, + "N73cBw": { + "defaultMessage": "WASM memory" + }, "NACY16": { "defaultMessage": "為什麼需要設定錢包 ?", "description": "src/components/Forms/PaymentForm/BindWallet/index.tsx" @@ -1614,6 +1679,9 @@ "defaultMessage": "確認刪除後,{commentType}會立即消失。", "description": "src/components/CircleComment/DropdownActions/DeleteComment/Dialog.tsx" }, + "NloKwX": { + "defaultMessage": "Proof worker" + }, "NmhF45": { "defaultMessage": "通過添加標籤幫助讀者更好地找到你的作品。如果沒有合適的標籤,你可以創建新的。" }, @@ -1751,6 +1819,9 @@ "Q8Qw5B": { "defaultMessage": "描述" }, + "QHRze5": { + "defaultMessage": "閒聊" + }, "QKJWqd": { "defaultMessage": "收藏", "description": "src/components/Buttons/TagBookmark/Bookmark.tsx" @@ -1826,6 +1897,9 @@ "defaultMessage": "請再次輸入交易密碼", "description": "src/components/Forms/PaymentForm/SetPassword/index.tsx" }, + "RYc4X0": { + "defaultMessage": "Browser proof" + }, "Rc4Oij": { "defaultMessage": "火閃電" }, @@ -1911,6 +1985,9 @@ "Sj+TN8": { "defaultMessage": "公告" }, + "Su1LcW": { + "defaultMessage": "開啟後,新發佈作品預設可輸出到 Fediverse。" + }, "SuRTsQ": { "defaultMessage": "註冊 ISCN" }, @@ -2008,6 +2085,9 @@ "USOHRK": { "defaultMessage": "修改失敗,請稍候重試" }, + "USq+mM": { + "defaultMessage": "Pornographic advertising" + }, "UUJzml": { "defaultMessage": "Logbook 2.0 剛剛推出。如果你是 Traveloggers 的所有者,且尚未領取,你可以從新的日誌頁面領取:" }, @@ -2051,6 +2131,12 @@ "VBve8d": { "defaultMessage": "搬家到 Matters" }, + "VDclc3": { + "defaultMessage": "你已加入閒聊頻道,即日起發出的閒聊將顯示於此頻道" + }, + "VFQGq7": { + "defaultMessage": "Check result" + }, "VMQPwZ": { "defaultMessage": "過往活動", "description": "src/views/Campaigns/Feeds/Tabs/index.tsx" @@ -2244,6 +2330,9 @@ "Y9IYvj": { "defaultMessage": "沒有適合的頻道" }, + "YC2b3b": { + "defaultMessage": "Fediverse 聯邦發佈" + }, "YDMrKK": { "defaultMessage": "用戶" }, @@ -2276,6 +2365,9 @@ "defaultMessage": "因違反用戶協定而被封存,", "description": "src/views/ArticleDetail/StickyTopBanner/index.tsx" }, + "ZAoAcG": { + "defaultMessage": "Threads, Mastodon, Misskey 這些地方的粉絲也會看到" + }, "ZAs170": { "defaultMessage": "基本資料", "description": "src/views/Circle/Settings/index.tsx" @@ -2320,6 +2412,10 @@ "defaultMessage": "讀者數量:訪問過作品頁的不重複的登入用戶數加匿名 IP 數(數據週期更新,可能存在延遲)", "description": "src/views/Me/Works/Published/SortTabs.tsx" }, + "a5ZQOK": { + "defaultMessage": "查看公開紀錄", + "description": "src/components/Comment/DropdownActions/CommunityWatchRemoveComment.tsx" + }, "aCTmEO": { "defaultMessage": "我還沒有錢包", "description": "src/components/Forms/SelectAuthMethodForm/WalletFeed.tsx" @@ -2368,6 +2464,9 @@ "defaultMessage": "前往首頁", "description": "src/views/Callback/UI.tsx" }, + "atmn17": { + "defaultMessage": "Open isolated prover" + }, "awW+lk": { "defaultMessage": "進行中…", "description": "src/components/Transaction/State/index.tsx" @@ -2424,6 +2523,9 @@ "defaultMessage": "將你的評論在 {commentArticle} 中置頂", "description": "src/components/Notice/CommentNotice/CommentPinnedNotice.tsx" }, + "cB6zjC": { + "defaultMessage": "Signed TW FidO proof input is kept in this browser for the next proving step." + }, "cCpbBu": { "defaultMessage": "頻道熱門作者" }, @@ -2502,6 +2604,10 @@ "d5bM8A": { "defaultMessage": "投稿日程⋯" }, + "d95AX1": { + "defaultMessage": "我再想想", + "description": "src/views/HottestMoments/Apply/Dialog/index.tsx" + }, "dAPUJp": { "defaultMessage": "流星雨的絢爛光芒足以點亮夜空。流星號徽章紀念你曾參與「遊牧者計畫」。", "description": "src/views/User/UserProfile/BadgeNomadLabel/index.tsx" @@ -2538,6 +2644,9 @@ "defaultMessage": "評論和回覆", "description": "src/views/Me/Settings/Notifications/GeneralSettings/index.tsx" }, + "dWt8c/": { + "defaultMessage": "Open the isolated browser container. It does not load the normal Matters bundle, so cross-origin isolation can be enabled for the zkID worker." + }, "dZlT9q": { "defaultMessage": "已推薦你的這篇作品到頻道:{channelNames},你對結果滿意嗎?" }, @@ -2574,6 +2683,10 @@ "eNv3Wm": { "defaultMessage": "已推薦你的這篇作品到頻道:{channelNames}" }, + "eRTBgt": { + "defaultMessage": "本則貼文已由守望相助隊檢舉", + "description": "src/components/Comment/Content/index.tsx" + }, "eTpiYa": { "defaultMessage": "尚無支持數據" }, @@ -2653,6 +2766,9 @@ "fWZYP5": { "defaultMessage": "置頂" }, + "fbxogW": { + "defaultMessage": "Personhood" + }, "fko+MR": { "defaultMessage": "重試提領" }, @@ -2771,10 +2887,18 @@ "defaultMessage": "餘額不足:", "description": "src/components/Balance/index.tsx" }, + "hdbKK1": { + "defaultMessage": "閒聊", + "description": "src/components/Layout/SideChannelNav/index.tsx" + }, "hgtWIO": { "defaultMessage": "作品被關聯", "description": "src/views/Me/Settings/Notifications/GeneralSettings/index.tsx" }, + "hifvI7": { + "defaultMessage": "圍爐上鎖作品", + "description": "src/views/ArticleDetail/Wall/Circle/index.tsx" + }, "hk2aiz": { "defaultMessage": "追蹤了你的圍爐", "description": "src/components/Notice/CircleNotice/CircleNewUserNotice.tsx" @@ -2912,10 +3036,6 @@ "kHMa3H": { "defaultMessage": "密碼不一致" }, - "kPylKK": { - "defaultMessage": "圍爐", - "description": "src/views/Me/Settings/Notifications/index.tsx" - }, "kS3vTS": { "defaultMessage": "Liker ID", "description": "src/views/Me/Settings/Misc/LikerID.tsx" @@ -2931,6 +3051,9 @@ "defaultMessage": "確認移出", "description": "src/components/Dialogs/RemoveArticleCollectionDialog/index.tsx" }, + "kqQp7b": { + "defaultMessage": "No signed proof input was found. Return to the TW FidO step and check the result again." + }, "ksIL/T": { "defaultMessage": "貼心提醒:此錢包位址不同於您用於登入 Matters 的錢包位址" }, @@ -2980,6 +3103,9 @@ "lO7wKc": { "defaultMessage": "再想想" }, + "lTleCS": { + "defaultMessage": "Checking" + }, "lYVn31": { "defaultMessage": "此作品已加入排程,請前往「我的創作」頁面確認" }, @@ -3027,6 +3153,9 @@ "defaultMessage": "站內閱讀熱門排行", "description": "src/views/Circle/Analytics/ContentAnalytics/index.tsx" }, + "mFn/Vv": { + "defaultMessage": "已更新 Fediverse 設定" + }, "mJEqC/": { "defaultMessage": "跳轉至 {href}" }, @@ -3034,6 +3163,9 @@ "defaultMessage": "取消收藏", "description": "src/components/Buttons/TagBookmark/Unbookmark.tsx" }, + "mMji02": { + "defaultMessage": "開啟" + }, "mPe6DK": { "defaultMessage": "訂閱了你的圍爐", "description": "src/components/Notice/CircleNotice/CircleNewUserNotice.tsx" @@ -3137,6 +3269,10 @@ "defaultMessage": "電子信箱不正確", "description": "USER_EMAIL_INVALID" }, + "oA2Pur": { + "defaultMessage": "濫發廣告", + "description": "src/components/Comment/DropdownActions/CommunityWatchRemoveComment.tsx" + }, "oEHAIT": { "defaultMessage": "取消排程", "description": "confirm cancel schedule button" @@ -3159,6 +3295,9 @@ "defaultMessage": "追蹤中", "description": "src/components/UserProfile/index.tsx" }, + "ol0msv": { + "defaultMessage": "Copy TW FidO link" + }, "on+DYO": { "defaultMessage": "確認報名" }, @@ -3168,6 +3307,13 @@ "orIq4X": { "defaultMessage": "支持超過 100 次" }, + "p/Sz0T": { + "defaultMessage": "恢復留言", + "description": "src/components/Comment/Content/index.tsx" + }, + "p556q3": { + "defaultMessage": "Copied" + }, "p5qZnJ": { "defaultMessage": "讚賞了", "description": "src/components/Notice/ArticleNotice/ArticleNewAppreciationNotice.tsx" @@ -3196,6 +3342,9 @@ "ptTHBL": { "defaultMessage": "支持號召" }, + "pw6gGa": { + "defaultMessage": "ID number" + }, "pzTOmv": { "defaultMessage": "追蹤者" }, @@ -3308,6 +3457,10 @@ "defaultMessage": "開始修改", "description": "src/components/Dialogs/ReviseArticleDialog/index.tsx" }, + "rW+2ID": { + "defaultMessage": "已由守望相助隊檢舉", + "description": "src/components/Comment/DropdownActions/CommunityWatchRemoveComment.tsx" + }, "rXnmeE": { "defaultMessage": "確認發送" }, @@ -3352,6 +3505,9 @@ "sbhWw+": { "defaultMessage": "排程發布" }, + "scWFV3": { + "defaultMessage": "Spam advertising" + }, "sfj+KG": { "defaultMessage": "結果以鏈上紀錄為主,稍後同步至 Matters", "description": "src/components/Forms/PaymentForm/Processing/index.tsx" @@ -3384,6 +3540,9 @@ "t2EpN/": { "defaultMessage": "持續與 {network} 網絡同步,稍後更新至 Matters" }, + "t2rFN5": { + "defaultMessage": "Create a signing ticket, open the TW FidO app, then return here for the proof input check." + }, "tBt9u0": { "defaultMessage": "登入", "description": "src/components/Forms/EmailLoginForm/index.tsx" @@ -3414,9 +3573,15 @@ "tWCQCd": { "defaultMessage": "交易合約已將 {amount} USDT 發送至綁定錢包" }, + "tYDhrI": { + "defaultMessage": "申請通過之後,個人發出的動態將顯示於此頻道" + }, "tZKvnZ": { "defaultMessage": "取消喜歡動態" }, + "tsUFZX": { + "defaultMessage": "分享到聯邦宇宙" + }, "tzq2+W": { "defaultMessage": "寄出邀請", "description": "src/views/Circle/Settings/ManageInvitation/AddInvitationDialog/PreSend.tsx" @@ -3425,6 +3590,10 @@ "defaultMessage": "我的圍爐", "description": "src/views/Me/Settings/Notifications/Circle/index.tsx" }, + "u4QNpM": { + "defaultMessage": "恢復失敗,請稍後再試", + "description": "src/components/Comment/Content/index.tsx" + }, "u5aHb4": { "defaultMessage": "複製連結" }, @@ -3517,8 +3686,9 @@ "defaultMessage": "去綁定", "description": "src/components/Dialogs/BindEmailHintDialog/Content.tsx" }, - "vH8sCb": { - "defaultMessage": "我的圍爐" + "vJQzbe": { + "defaultMessage": "已恢復留言", + "description": "src/components/Comment/Content/index.tsx" }, "vJd1we": { "defaultMessage": "評論不存在", @@ -3535,6 +3705,9 @@ "vX2bDy": { "defaultMessage": "關聯作品" }, + "vXgChH": { + "defaultMessage": "操作失敗,請稍後再試" + }, "va8Rnw": { "defaultMessage": "作者已關閉評論區" }, @@ -3652,10 +3825,6 @@ "defaultMessage": "ID 已被使用,請修改後再試", "description": "src/components/Dialogs/SetUserNameDialog/Content.tsx" }, - "x7kxvC": { - "defaultMessage": "訂閱解鎖", - "description": "src/views/ArticleDetail/Wall/Circle/index.tsx" - }, "xDhqHi": { "defaultMessage": "確認送出", "description": "src/components/Forms/PaymentForm/PayTo/Confirm/index.tsx" @@ -3683,6 +3852,9 @@ "defaultMessage": "留言", "description": "src/views/Me/Settings/Notifications/Circle/index.tsx" }, + "xhfdqv": { + "defaultMessage": "Creating" + }, "xiV3Wz": { "defaultMessage": "更多活動", "description": "src/views/CampaignDetail/InfoHeader/OtherCampaigns/index.tsx" @@ -3768,10 +3940,16 @@ "z3uIHQ": { "defaultMessage": "取消點讚" }, + "z5UXPc": { + "defaultMessage": "申請加入" + }, "z91BKe": { "defaultMessage": "已封存作品", "description": "src/components/Notice/NoticeArticleTitle.tsx" }, + "z9jdNT": { + "defaultMessage": "Start browser proof" + }, "zAK5G+": { "defaultMessage": "登入連結已發送至 {email}", "description": "src/components/Forms/Verification/LinkSent.tsx" diff --git a/package.json b/package.json index d9017e7484..a2e92b8f6d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matters-web", - "version": "6.8.0", + "version": "6.11.0", "description": "codebase of Matters' website", "author": "Matters ", "engines": { @@ -12,6 +12,10 @@ "dev": "PORT=\"${PORT:-3000}\"; next dev", "test": "concurrently -c \"auto\" \"npm run test:unit\" \"npm run test:e2e\"", "test:e2e": "playwright test", + "test:e2e:smoke": "playwright test --grep @smoke --no-deps", + "test:e2e:staging": "playwright test --grep \"@smoke|@auth-smoke|@regression\"", + "test:e2e:mutation": "playwright test --grep @mutation", + "test:e2e:prod-smoke": "playwright test --grep @smoke --grep-invert \"@mutation|@admin|@payment\" --no-deps", "test:e2e:prepare": "playwright install --with-deps", "test:unit": "vitest run", "test:unit:coverage": "vitest run --coverage", diff --git a/public/manifest.json b/public/manifest.json index 14baf98a4c..b69ec5a602 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -1,5 +1,5 @@ { - "Scope": "/", + "scope": "/", "background_color": "#ffffff", "description": "Matters 致力搭建去中心化的寫作社群與內容生態。基於 IPFS 技術,令創作不受制於任何平台,獨立性得到保障;引入加密貨幣,以收入的形式回饋給作者;代碼開源,建立創作者自治社區。", "display": "standalone", diff --git a/public/static/icons/24px/badge-carbon-based.svg b/public/static/icons/24px/badge-carbon-based.svg new file mode 100644 index 0000000000..998ff030b6 --- /dev/null +++ b/public/static/icons/24px/badge-carbon-based.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/static/icons/24px/badge-community-watch.svg b/public/static/icons/24px/badge-community-watch.svg new file mode 100644 index 0000000000..447b3ae1f0 --- /dev/null +++ b/public/static/icons/24px/badge-community-watch.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/common/enums/article.ts b/src/common/enums/article.ts index 1d2cf51455..4ce42b151b 100644 --- a/src/common/enums/article.ts +++ b/src/common/enums/article.ts @@ -1,4 +1,4 @@ -export const COMMENTS_COUNT = 40 +export const COMMENTS_COUNT = 39 export const TOOLBAR_FIXEDTOOLBAR_ID = 'toolbar/fixedToolbar/id' export const COMMENT_FEED_ID_PREFIX = 'comment-feed-' export const SUPPORT_TAB_PREFERENCE_KEY = 'support-tab-preference-key' diff --git a/src/common/enums/communityWatch.ts b/src/common/enums/communityWatch.ts new file mode 100644 index 0000000000..2a6347137a --- /dev/null +++ b/src/common/enums/communityWatch.ts @@ -0,0 +1,4 @@ +export const COMMUNITY_WATCH_RECORD_URL = 'https://community-watch.matters.town' + +export const toCommunityWatchRecordUrl = (uuid: string) => + `${COMMUNITY_WATCH_RECORD_URL}/records/${uuid}/` diff --git a/src/common/enums/events.ts b/src/common/enums/events.ts index 31bf840057..d4a3ca8e8f 100644 --- a/src/common/enums/events.ts +++ b/src/common/enums/events.ts @@ -81,6 +81,7 @@ export enum UNIVERSAL_AUTH_TRIGGER { momentLike = 'momentLike', applyCampaign = 'applyCampaign', collectionLike = 'collectionLike', + applyMomentFeed = 'applyMomentFeed', } // Editor diff --git a/src/common/enums/index.ts b/src/common/enums/index.ts index f13ae09916..9a7c853655 100644 --- a/src/common/enums/index.ts +++ b/src/common/enums/index.ts @@ -2,6 +2,7 @@ export * from './analytics' export * from './article' export * from './channel' export * from './chart' +export * from './communityWatch' export * from './contract' export * from './cookie' export * from './draft' diff --git a/src/common/enums/oauth.ts b/src/common/enums/oauth.ts index 0cbdf0e9c2..ee45ed93c3 100644 --- a/src/common/enums/oauth.ts +++ b/src/common/enums/oauth.ts @@ -303,6 +303,7 @@ export const CALLBACK_VERIFIER = { export const OAUTH_CALLBACK_PROVIDERS = { Google: 'google', Twitter: 'twitter', + Threads: 'threads', } export const CALLBACK_PROVIDERS = { diff --git a/src/common/enums/route.ts b/src/common/enums/route.ts index 4601366bb8..d8229b49fc 100644 --- a/src/common/enums/route.ts +++ b/src/common/enums/route.ts @@ -9,6 +9,7 @@ type ROUTE_KEY = | 'HOME' | 'FEATURED' | 'HOTTEST' + | 'HOTTEST_MOMENTS' | 'NEWEST' | 'CHANNEL' | 'FOLLOW' @@ -59,6 +60,8 @@ type ROUTE_KEY = | 'ME_SETTINGS_NOTIFICATIONS' | 'ME_SETTINGS_MISC' | 'ME_SETTINGS_BLOCKED' + | 'ME_SETTINGS_PERSONHOOD_FEASIBILITY' + | 'ME_SETTINGS_PERSONHOOD_PROVE' | 'ME_DRAFT_NEW' | 'ME_DRAFT_DETAIL' | 'ME_DRAFT_DETAIL_OPTIONS' @@ -113,6 +116,14 @@ export const PROTECTED_ROUTES: { pathname: '/me/settings/misc', }, { key: 'ME_SETTINGS_BLOCKED', pathname: '/me/settings/blocked' }, + { + key: 'ME_SETTINGS_PERSONHOOD_FEASIBILITY', + pathname: '/me/settings/personhood/feasibility', + }, + { + key: 'ME_SETTINGS_PERSONHOOD_PROVE', + pathname: '/me/settings/personhood/prove', + }, // Article { key: 'ARTICLE_DETAIL_EDIT', pathname: '/a/[shortHash]/edit' }, @@ -151,6 +162,7 @@ export const ROUTES: { { key: 'HOME', pathname: '/' }, { key: 'FEATURED', pathname: '/featured' }, { key: 'HOTTEST', pathname: '/hottest' }, + { key: 'HOTTEST_MOMENTS', pathname: '/moments' }, { key: 'NEWEST', pathname: '/newest' }, { key: 'CHANNEL', pathname: '/c/[shortHash]' }, { key: 'FOLLOW', pathname: '/follow' }, diff --git a/src/common/utils/analytics.ts b/src/common/utils/analytics.ts index 19aaa06180..a29187e50a 100644 --- a/src/common/utils/analytics.ts +++ b/src/common/utils/analytics.ts @@ -115,6 +115,9 @@ export interface ClickButtonProp { | `campaign_detail_tab_${string}` | `user_profile_tab_${string}` | `follow_tab_${string}` + | 'login_threads' + | 'bind_threads' + | 'unbind_threads' pageType?: PageType pageComponent?: PageComponent note?: string diff --git a/src/common/utils/comment.test.ts b/src/common/utils/comment.test.ts index 96ab784e7c..984db1a9f5 100644 --- a/src/common/utils/comment.test.ts +++ b/src/common/utils/comment.test.ts @@ -24,6 +24,20 @@ describe('utils/comment/filterComments', () => { expect(result.length).toEqual(0) }) + it('should not filter out community watch removed comments', () => { + const comments = [ + { + ...MOCK_COMMENT, + state: CommentState.Banned, + communityWatchAction: { + uuid: 'community-watch-action-uuid', + }, + }, + ] + const result = filterComments(comments) + expect(result.length).toEqual(comments.length) + }) + it('should filter out comment that are decendant', () => { const comments = [ { diff --git a/src/common/utils/comment/index.ts b/src/common/utils/comment/index.ts index 364e51f2f0..2d308676fb 100644 --- a/src/common/utils/comment/index.ts +++ b/src/common/utils/comment/index.ts @@ -12,6 +12,9 @@ import styles from './styles.module.css' */ interface Comment { state: string + communityWatchAction?: { + uuid: string + } | null parentComment?: { id: string } | null @@ -25,7 +28,8 @@ const filterComment = (comment: Comment) => { // skip if comment's state is active or collapse if ( comment.state === CommentState.Active || - comment.state === CommentState.Collapsed + comment.state === CommentState.Collapsed || + (comment.state === CommentState.Banned && comment.communityWatchAction) ) { return true } diff --git a/src/common/utils/graphqlAuthBoundary.test.ts b/src/common/utils/graphqlAuthBoundary.test.ts new file mode 100644 index 0000000000..ca109718b1 --- /dev/null +++ b/src/common/utils/graphqlAuthBoundary.test.ts @@ -0,0 +1,54 @@ +import { readdirSync, readFileSync, statSync } from 'fs' +import path from 'path' +import { describe, expect, it } from 'vitest' + +const root = process.cwd() +const sourceRoot = path.join(root, 'src') + +const allowedUserFeatureFlagFiles = new Set([ + path.join(sourceRoot, 'common/utils/graphqlAuthBoundary.test.ts'), + path.join( + sourceRoot, + 'views/User/UserProfile/DropdownActions/ToggleCommunityWatch/Button.tsx' + ), + path.join( + sourceRoot, + 'views/User/UserProfile/DropdownActions/ToggleCommunityWatch/Dialog.tsx' + ), + path.join(sourceRoot, 'common/utils/types/index.ts'), + path.join(sourceRoot, 'stories/mocks/index.ts'), +]) + +const ignoredDirs = new Set(['gql']) + +const collectSourceFiles = (dir: string): string[] => { + const entries = readdirSync(dir) + + return entries.flatMap((entry) => { + const filePath = path.join(dir, entry) + const stat = statSync(filePath) + + if (stat.isDirectory()) { + return ignoredDirs.has(entry) ? [] : collectSourceFiles(filePath) + } + + return filePath.match(/\.(ts|tsx)$/) ? [filePath] : [] + }) +} + +describe('GraphQL auth boundaries', () => { + it('keeps UserFeatureFlagType usage inside admin-only surfaces', () => { + const violations = collectSourceFiles(sourceRoot).filter((filePath) => { + if (allowedUserFeatureFlagFiles.has(filePath)) { + return false + } + + const source = readFileSync(filePath, 'utf8') + return source.includes('UserFeatureFlagType') + }) + + expect(violations.map((filePath) => path.relative(root, filePath))).toEqual( + [] + ) + }) +}) diff --git a/src/common/utils/oauth.ts b/src/common/utils/oauth.ts index 0c00e233a3..ba27cedc05 100644 --- a/src/common/utils/oauth.ts +++ b/src/common/utils/oauth.ts @@ -89,6 +89,14 @@ export const twitterOauthUrl = async (type: OauthType, oauthToken: string) => { return url } +export const threadsOauthUrl = async (type: OauthType) => { + const { state } = await generateSocialOauthParams(type) + const clientId = process.env.NEXT_PUBLIC_THREADS_CLIENT_ID + const redirectUri = `https://${process.env.NEXT_PUBLIC_SITE_DOMAIN}/callback/${CALLBACK_PROVIDERS.Threads}` + const url = `https://threads.net/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=threads_basic&state=${state}` + return url +} + export const signupCallbackUrl = (email: string, referralCode?: string) => { return `https://${process.env.NEXT_PUBLIC_SITE_DOMAIN}/callback/${ CALLBACK_PROVIDERS.EmailSignup diff --git a/src/common/utils/resolvers/gatewayUrls.ts b/src/common/utils/resolvers/gatewayUrls.ts index 463af5fc22..0078795665 100644 --- a/src/common/utils/resolvers/gatewayUrls.ts +++ b/src/common/utils/resolvers/gatewayUrls.ts @@ -1,8 +1,8 @@ // const TEST_HASH = 'Qmaisz6NMhDB51cCvNWa1GMS7LU1pAxdF4Ld6Ft9kZEP2a' const PUBLIC_GATEWAYS: string[] = [ - 'https://:cidv1.ipfs.w3s.link', 'https://ipfs-gateway.matters.town/ipfs/:hash', 'https://ipfs.io/ipfs/:hash', + 'https://:cidv1.ipfs.w3s.link', 'https://cloudflare-ipfs.com/ipfs/:hash', ] diff --git a/src/common/utils/types/index.ts b/src/common/utils/types/index.ts index b1e5de6557..d8382f8c65 100644 --- a/src/common/utils/types/index.ts +++ b/src/common/utils/types/index.ts @@ -41,4 +41,93 @@ export default gql` zh_hans zh_hant } + + # Temporary schema extension for Community Watch development. + # matters-web CI generates GraphQL types against deployed schemas. Keep this + # block until the server schema exposing these fields is deployed there. + extend enum UserFeatureFlagType { + communityWatch + fediverseBeta + } + + extend enum BadgeType { + carbon_based + community_watch + } + + enum FederationAuthorSettingState { + enabled + disabled + } + + enum FederationArticleSettingState { + inherit + enabled + disabled + } + + type UserFederationSetting { + userId: ID! + state: FederationAuthorSettingState! + updatedBy: ID + } + + type ArticleFederationSetting { + articleId: ID! + state: FederationArticleSettingState! + updatedBy: ID + } + + extend type User { + federationSetting: UserFederationSetting + } + + extend type Article { + federationSetting: ArticleFederationSetting + } + + input SetViewerFederationSettingInput { + state: FederationAuthorSettingState! + } + + input SetArticleFederationSettingInput { + id: ID! + state: FederationArticleSettingState! + } + + extend type Mutation { + setViewerFederationSetting( + input: SetViewerFederationSettingInput! + ): UserFederationSetting! + + setArticleFederationSetting( + input: SetArticleFederationSettingInput! + ): ArticleFederationSetting! + } + + enum CommunityWatchRemoveCommentReason { + porn_ad + spam_ad + } + + type CommunityWatchAction { + createdAt: DateTime! + reason: CommunityWatchRemoveCommentReason! + uuid: ID! + } + + input CommunityWatchRemoveCommentInput { + id: ID! + reason: CommunityWatchRemoveCommentReason! + } + + extend type Comment { + communityWatchAction: CommunityWatchAction + } + + extend type Mutation { + communityWatchRemoveComment( + input: CommunityWatchRemoveCommentInput! + ): Comment! + } ` diff --git a/src/components/ArticleDigest/Feed/FooterActions/FooterActions.test.tsx b/src/components/ArticleDigest/Feed/FooterActions/FooterActions.test.tsx index b4410f8cb6..c85be43698 100644 --- a/src/components/ArticleDigest/Feed/FooterActions/FooterActions.test.tsx +++ b/src/components/ArticleDigest/Feed/FooterActions/FooterActions.test.tsx @@ -16,11 +16,11 @@ describe('', () => { expect(screen.getByLabelText('Donation count')).toBeTruthy() }) - it('should render circle', () => { + it('should hide circle name when in circle (sunsetting)', () => { render() expect( - screen.getByText(MOCK_ARTILCE.access.circle.displayName) - ).toBeTruthy() + screen.queryByText(MOCK_ARTILCE.access.circle.displayName) + ).toBeNull() }) it('should not render metadata', () => { diff --git a/src/components/ArticleDigest/Feed/FooterActions/index.tsx b/src/components/ArticleDigest/Feed/FooterActions/index.tsx index 4bd761670f..b63fac2540 100644 --- a/src/components/ArticleDigest/Feed/FooterActions/index.tsx +++ b/src/components/ArticleDigest/Feed/FooterActions/index.tsx @@ -6,13 +6,9 @@ import IconBook2 from '@/public/static/icons/24px/book2.svg' import IconPaywall from '@/public/static/icons/24px/paywall.svg' import IconStar from '@/public/static/icons/24px/star.svg' import { toPath } from '~/common/utils' -import { - CircleDigest, - Icon, - LanguageContext, - TextIcon, - ViewerContext, -} from '~/components' +// FEATURE IS SUNSETTING: CircleDigest import is no longer used here +// import { CircleDigest } from '~/components' +import { Icon, LanguageContext, TextIcon, ViewerContext } from '~/components' import { FooterActionsArticlePublicFragment } from '~/gql/graphql' import DropdownActions, { DropdownActionsControls } from '../../DropdownActions' @@ -79,7 +75,11 @@ const FooterActions = ({ {tag} - {hasCircle && circle && ( + {/* FEATURE IS SUNSETTING: circle name is hidden, lock icon kept */} + {hasCircle && circle && article.access.type === 'paywall' && ( + + )} + {/* {hasCircle && circle && ( - )} + )} */} {hasTogglePinChannelArticles && channelId && pinned && ( { const [loadingState, setLoadingState] = useState('') const isGoogleLoading = loadingState === 'Google' const isTwitterLoading = loadingState === 'Twitter' + // const isThreadsLoading = loadingState === 'Threads' useEffect(() => { return setLoadingState('') @@ -55,6 +64,13 @@ export const AuthNormalFeed = ({ gotoEmailSignup, gotoEmailLogin }: Props) => { } } + // const gotoThreads = async () => { + // analytics.trackEvent('click_button', { type: 'login_threads' }) + // setLoadingState('Threads') + // const url = await threadsOauthUrl(oauthType) + // router.push(url) + // } + return ( <>
    @@ -88,6 +104,19 @@ export const AuthNormalFeed = ({ gotoEmailSignup, gotoEmailLogin }: Props) => { )} + {/* +
  • + + + + Threads + {isThreadsLoading && ( + + + + )} +
  • + */}
diff --git a/src/components/Comment/Content/Content.test.tsx b/src/components/Comment/Content/Content.test.tsx index e12a50c160..a24c32e211 100644 --- a/src/components/Comment/Content/Content.test.tsx +++ b/src/components/Comment/Content/Content.test.tsx @@ -1,12 +1,17 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, render, screen } from '~/common/utils/test' -import { CommentState } from '~/gql/graphql' -import { MOCK_COMMENT } from '~/stories/mocks' +import { processViewer, ViewerContext } from '~/components' +import { CommentState, UserRole } from '~/gql/graphql' +import { MOCK_COMMENT, MOCK_USER } from '~/stories/mocks' import { CommentContent } from './' describe('', () => { + afterEach(() => { + vi.unstubAllEnvs() + }) + it('should render a Comment.Content', () => { render() @@ -68,4 +73,87 @@ describe('', () => { screen.getByText('This comment has been deleted by the author') ).toBeInTheDocument() }) + + it('should render a Community Watch placeholder link', () => { + render( + + ) + + const $link = screen.getByRole('link', { + name: '本則貼文已由守望相助隊檢舉', + }) + expect($link).toHaveAttribute( + 'href', + 'https://community-watch.matters.town/records/community-watch-action-uuid/' + ) + expect( + screen.queryByRole('button', { name: /恢復留言|Restore comment/ }) + ).not.toBeInTheDocument() + }) + + it('should not render a Community Watch restore button for admins outside admin view', () => { + const admin = processViewer({ + ...MOCK_USER, + status: { + ...MOCK_USER.status, + role: UserRole.Admin, + }, + }) + + render( + + + + ) + + expect( + screen.queryByRole('button', { name: /恢復留言|Restore comment/ }) + ).not.toBeInTheDocument() + }) + + it('should render a Community Watch restore button for admins in admin view', () => { + vi.stubEnv('NEXT_PUBLIC_ADMIN_VIEW', 'true') + + const admin = processViewer({ + ...MOCK_USER, + status: { + ...MOCK_USER.status, + role: UserRole.Admin, + }, + }) + + render( + + + + ) + + expect( + screen.getByRole('button', { name: /恢復留言|Restore comment/ }) + ).toBeInTheDocument() + }) }) diff --git a/src/components/Comment/Content/index.tsx b/src/components/Comment/Content/index.tsx index eb7def587f..946bdbbcad 100644 --- a/src/components/Comment/Content/index.tsx +++ b/src/components/Comment/Content/index.tsx @@ -1,18 +1,35 @@ +import { useApolloClient } from '@apollo/client' import classNames from 'classnames' import gql from 'graphql-tag' +import Link from 'next/link' import { useContext } from 'react' import { FormattedMessage } from 'react-intl' -import { COMMENT_TYPE_TEXT, TEST_ID } from '~/common/enums' -import { Expandable, LanguageContext } from '~/components' +import { + COMMENT_TYPE_TEXT, + TEST_ID, + toCommunityWatchRecordUrl, +} from '~/common/enums' +import { + Button, + Expandable, + LanguageContext, + toast, + useMutation, + ViewerContext, +} from '~/components' import { CommentContentCommentPrivateFragment, CommentContentCommentPublicFragment, + CommentState, + RestoreCommunityWatchCommentMutation, } from '~/gql/graphql' import Collapsed from './Collapsed' import styles from './styles.module.css' +const isAdminView = () => process.env.NEXT_PUBLIC_ADMIN_VIEW === 'true' + interface ContentProps { comment: CommentContentCommentPublicFragment & Partial @@ -31,6 +48,9 @@ const fragments = { id content state + communityWatchAction { + uuid + } } `, private: gql` @@ -39,12 +59,100 @@ const fragments = { author { id isBlocked + status { + state + } } } `, }, } +const RESTORE_COMMUNITY_WATCH_COMMENT = gql` + mutation RestoreCommunityWatchComment($uuid: ID!, $note: String) { + restoreCommunityWatchComment(input: { uuid: $uuid, note: $note }) { + uuid + actionState + appealState + reviewState + commentId + } + } +` + +const CommunityWatchRestoreButton = ({ + commentId, + actionUuid, +}: { + commentId: string + actionUuid: string +}) => { + const client = useApolloClient() + const [restoreComment, { loading }] = + useMutation( + RESTORE_COMMUNITY_WATCH_COMMENT + ) + + const restore = async () => { + try { + await restoreComment({ + variables: { + uuid: actionUuid, + note: 'Restored from Matters frontend admin action', + }, + update: (cache) => { + cache.modify({ + id: cache.identify({ __typename: 'Comment', id: commentId }), + fields: { + state: () => CommentState.Active, + communityWatchAction: () => null, + }, + }) + }, + }) + await client.refetchQueries({ include: 'active' }) + + toast.success({ + message: ( + + ), + }) + } catch (error) { + console.error(error) + toast.error({ + message: ( + + ), + }) + } + } + + return ( + + ) +} + export const CommentContent = ({ comment, size, @@ -55,8 +163,10 @@ export const CommentContent = ({ expandable = true, }: ContentProps) => { const { lang } = useContext(LanguageContext) + const viewer = useContext(ViewerContext) const { content, state } = comment const isBlocked = comment.author?.isBlocked + const communityWatchAction = comment.communityWatchAction const contentClasses = classNames({ [styles.content]: true, @@ -64,6 +174,26 @@ export const CommentContent = ({ [styles.inactive]: state === 'archived' || state === 'banned', }) + if (state === 'banned' && communityWatchAction?.uuid) { + return ( +

+ + + + {isAdminView() && viewer.isAdmin && ( + + )} +

+ ) + } + if (state === 'banned') { return (

diff --git a/src/components/Comment/Content/styles.module.css b/src/components/Comment/Content/styles.module.css index 2f54e7c113..1e4bb2642e 100644 --- a/src/components/Comment/Content/styles.module.css +++ b/src/components/Comment/Content/styles.module.css @@ -11,3 +11,9 @@ .text15 { font-size: var(--text15); } + +.restoreButton { + margin-left: var(--sp8); + font-size: inherit; + vertical-align: baseline; +} diff --git a/src/components/Comment/DropdownActions/CommunityWatchRemoveComment.tsx b/src/components/Comment/DropdownActions/CommunityWatchRemoveComment.tsx new file mode 100644 index 0000000000..f3a40f865d --- /dev/null +++ b/src/components/Comment/DropdownActions/CommunityWatchRemoveComment.tsx @@ -0,0 +1,128 @@ +import gql from 'graphql-tag' +import { FormattedMessage } from 'react-intl' + +import IconCircleMinus from '@/public/static/icons/24px/circle-minus.svg' +import { toCommunityWatchRecordUrl } from '~/common/enums/communityWatch' +import { Icon, Menu, toast, useMutation } from '~/components' +import { + CommentState, + CommunityWatchRemoveCommentMutation, + CommunityWatchRemoveCommentReason, +} from '~/gql/graphql' + +const COMMUNITY_WATCH_REMOVE_COMMENT = gql` + mutation CommunityWatchRemoveComment( + $id: ID! + $reason: CommunityWatchRemoveCommentReason! + ) { + communityWatchRemoveComment(input: { id: $id, reason: $reason }) { + id + state + communityWatchAction { + uuid + reason + createdAt + } + } + } +` + +const CommunityWatchRemoveComment = ({ id }: { id: string }) => { + const [removeComment] = useMutation( + COMMUNITY_WATCH_REMOVE_COMMENT + ) + + const submit = async (reason: CommunityWatchRemoveCommentReason) => { + try { + const result = await removeComment({ + variables: { id, reason }, + optimisticResponse: { + communityWatchRemoveComment: { + __typename: 'Comment', + id, + state: CommentState.Banned, + communityWatchAction: { + __typename: 'CommunityWatchAction', + uuid: 'pending', + reason, + createdAt: new Date(), + }, + }, + }, + }) + const recordUuid = + result.data?.communityWatchRemoveComment.communityWatchAction?.uuid + + toast.success({ + message: ( + + ), + actions: + recordUuid && recordUuid !== 'pending' + ? [ + { + content: ( + + ), + onClick: () => { + window.open( + toCommunityWatchRecordUrl(recordUuid), + '_blank', + 'noopener,noreferrer' + ) + }, + }, + ] + : undefined, + }) + } catch (error) { + console.error(error) + toast.error({ + message: ( + + ), + }) + } + } + + return ( + <> + + } + icon={} + onClick={() => submit(CommunityWatchRemoveCommentReason.PornAd)} + /> + + } + icon={} + onClick={() => submit(CommunityWatchRemoveCommentReason.SpamAd)} + /> + + ) +} + +export default CommunityWatchRemoveComment diff --git a/src/components/Comment/DropdownActions/DropdownActions.test.tsx b/src/components/Comment/DropdownActions/DropdownActions.test.tsx index 037c386656..53c5db4d13 100644 --- a/src/components/Comment/DropdownActions/DropdownActions.test.tsx +++ b/src/components/Comment/DropdownActions/DropdownActions.test.tsx @@ -2,8 +2,9 @@ import { describe, expect, it } from 'vitest' import { TEST_ID } from '~/common/enums' import { cleanup, fireEvent, render, screen } from '~/common/utils/test' -import { CommentState } from '~/gql/graphql' -import { MOCK_COMMENT } from '~/stories/mocks' +import { processViewer, ViewerContext } from '~/components' +import { BadgeType, CommentState } from '~/gql/graphql' +import { MOCK_COMMENT, MOCK_USER } from '~/stories/mocks' import DropdownActions from './' @@ -83,4 +84,34 @@ describe('', () => { const $dialog = screen.getByTestId(TEST_ID.DIALOG_COMMENT_DELETE) expect($dialog).toBeInTheDocument() }) + + it('should render community watch actions for community watch members', async () => { + const viewer = processViewer({ + ...MOCK_USER, + info: { + ...MOCK_USER.info, + badges: [{ __typename: 'Badge', type: BadgeType.CommunityWatch }], + }, + }) + + render( + + + + ) + + const $button = screen.getByLabelText('More Actions') + expect($button).toBeInTheDocument() + fireEvent.click($button) + + expect( + screen.getByRole('menuitem', { name: '色情廣告' }) + ).toBeInTheDocument() + expect( + screen.getByRole('menuitem', { name: '濫發廣告' }) + ).toBeInTheDocument() + expect(screen.getByText('所有處理都會公開留痕')).toBeInTheDocument() + }) }) diff --git a/src/components/Comment/DropdownActions/index.tsx b/src/components/Comment/DropdownActions/index.tsx index 7f1cc988e6..120a756abf 100644 --- a/src/components/Comment/DropdownActions/index.tsx +++ b/src/components/Comment/DropdownActions/index.tsx @@ -26,6 +26,7 @@ import { CommentDropdownActionsCommentPublicFragment, } from '~/gql/graphql' +import CommunityWatchRemoveComment from './CommunityWatchRemoveComment' import CopyCommentButton from './CopyCommentButton' import DeleteComment from './DeleteComment' import type { DeleteCommentDialogProps } from './DeleteComment/Dialog' @@ -62,6 +63,7 @@ interface Controls { hasPin: boolean hasDelete: boolean hasReport: boolean + hasCommunityWatch: boolean hasAdmin: boolean } @@ -77,6 +79,7 @@ const fragments = { public: gql` fragment CommentDropdownActionsCommentPublic on Comment { id + type state content author { @@ -155,6 +158,7 @@ const BaseDropdownActions = ({ hasPin, hasDelete, hasReport, + hasCommunityWatch, hasAdmin, openDeleteCommentDialog, @@ -182,7 +186,25 @@ const BaseDropdownActions = ({ /> {_hasPin && } {hasReport && } - {(_hasPin || hasReport) && hasDelete && } + {hasCommunityWatch && ( + <> + + + + } + /> + + )} + {(_hasPin || hasReport || hasCommunityWatch) && hasDelete && ( + + )} {hasDelete && ( )} @@ -241,12 +263,17 @@ const DropdownActions = (props: DropdownActionsProps) => { const isTargetAuthor = viewer.id === targetAuthor?.id const isCommentAuthor = viewer.id === comment.author.id const isActive = comment.state === 'active' + const isCollapsed = comment.state === 'collapsed' const isDescendantComment = comment.parentComment const controls = { hasPin: hasPin && !!(isTargetAuthor && isActive && !isDescendantComment), hasDelete: (isCommentAuthor || (isTargetAuthor && isMoment)) && isActive, hasReport: !isCommentAuthor && !(isTargetAuthor && isMoment), + hasCommunityWatch: + viewer.isCommunityWatch && + (isActive || isCollapsed) && + (comment.type === 'article' || comment.type === 'moment'), hasAdmin: isAdminView && viewer.isAdmin, } diff --git a/src/components/Context/Viewer/Viewer.test.tsx b/src/components/Context/Viewer/Viewer.test.tsx new file mode 100644 index 0000000000..e67007cbb3 --- /dev/null +++ b/src/components/Context/Viewer/Viewer.test.tsx @@ -0,0 +1,52 @@ +import { render, screen } from '@testing-library/react' +import { useContext } from 'react' +import { describe, expect, it } from 'vitest' + +import { BadgeType } from '~/gql/graphql' +import { MOCK_USER } from '~/stories/mocks' + +import { processViewer, ViewerContext, ViewerProvider } from './' + +const ViewerProbe = () => { + const viewer = useContext(ViewerContext) + + return

{viewer.isCommunityWatch ? 'yes' : 'no'}
+} + +describe('Viewer', () => { + it('treats missing Community Watch badge as not Community Watch', () => { + expect(processViewer(MOCK_USER).isCommunityWatch).toBe(false) + }) + + it('detects Community Watch from the public user badge', () => { + expect( + processViewer({ + ...MOCK_USER, + info: { + ...MOCK_USER.info, + badges: [{ __typename: 'Badge', type: BadgeType.CommunityWatch }], + }, + }).isCommunityWatch + ).toBe(true) + }) + + it('provides Community Watch state without an extra OSS query', () => { + render( + + + + ) + + expect(screen.getByText('yes')).toBeInTheDocument() + }) +}) diff --git a/src/components/Context/Viewer/index.tsx b/src/components/Context/Viewer/index.tsx index 15bb437faf..529b5264e5 100644 --- a/src/components/Context/Viewer/index.tsx +++ b/src/components/Context/Viewer/index.tsx @@ -65,6 +65,7 @@ const ViewerFragments = { private: gql` fragment ViewerUserPrivate on User { id + isMomentFeedApplied info { socialAccounts { type @@ -97,6 +98,7 @@ export type Viewer = ViewerUser & { isFrozen: boolean isInactive: boolean isCivicLiker: boolean + isCommunityWatch: boolean isAdmin: boolean shouldSetupLikerID: boolean } @@ -111,6 +113,8 @@ export const processViewer = (viewer: ViewerUser): Viewer => { const isFrozen = state === 'frozen' const isInactive = isAuthed && (isBanned || isFrozen || isArchived) const isCivicLiker = viewer.liker.civicLiker + const isCommunityWatch = + viewer.info.badges?.some(({ type }) => type === 'community_watch') ?? false const isAdmin = viewer.status?.role === 'admin' const shouldSetupLikerID = isAuthed && !viewer.liker.likerId @@ -134,6 +138,7 @@ export const processViewer = (viewer: ViewerUser): Viewer => { isFrozen, isInactive, isCivicLiker, + isCommunityWatch, isAdmin, shouldSetupLikerID, } diff --git a/src/components/Dialogs/MomentDetailDialog/Comments.tsx b/src/components/Dialogs/MomentDetailDialog/Comments.tsx index d0a31b406e..7a2f091acc 100644 --- a/src/components/Dialogs/MomentDetailDialog/Comments.tsx +++ b/src/components/Dialogs/MomentDetailDialog/Comments.tsx @@ -89,7 +89,9 @@ const Comments = ({ moment, editing }: CommentsProps) => { ) const activeCommentsEdges = - comments.edges?.filter(({ node }) => node.state === 'active') || [] + comments.edges?.filter( + ({ node }) => node.state === 'active' || node.communityWatchAction + ) || [] const CommentsList = ( <> diff --git a/src/components/Dialogs/RemoveSocialLoginDialog/Content.tsx b/src/components/Dialogs/RemoveSocialLoginDialog/Content.tsx index 494327d6ff..dbeace1183 100644 --- a/src/components/Dialogs/RemoveSocialLoginDialog/Content.tsx +++ b/src/components/Dialogs/RemoveSocialLoginDialog/Content.tsx @@ -1,6 +1,8 @@ import React, { useState } from 'react' import { FormattedMessage } from 'react-intl' +// temporarily hidden: Threads +// import { analytics } from '~/common/utils' import { Dialog, toast, useMutation } from '~/components' import { RemoveSocialLoginMutation, SocialAccountType } from '~/gql/graphql' @@ -30,6 +32,10 @@ const RemoveSocailLoginDialogContent: React.FC = ({ const isFailure = step === 'failure' const remove = async () => { + // temporarily hidden: Threads + // if (type === SocialAccountType.Threads) { + // analytics.trackEvent('click_button', { type: 'unbind_threads' }) + // } try { await removeLogin({ variables: { diff --git a/src/components/Dialogs/SetUserNameDialog/hook.ts b/src/components/Dialogs/SetUserNameDialog/hook.ts index ab6e709927..20a0f97adc 100644 --- a/src/components/Dialogs/SetUserNameDialog/hook.ts +++ b/src/components/Dialogs/SetUserNameDialog/hook.ts @@ -26,8 +26,12 @@ export const useAvailableUserName = ({ const twitterId = viewer.info.socialAccounts.find( (s) => s.type === SocialAccountType.Twitter )?.userName + // temporarily hidden: Threads + // const threadsId = viewer.info.socialAccounts.find( + // (s) => s.type === SocialAccountType.Threads + // )?.userName - const presetUserName = (viewer.info.email as string) || googleId || twitterId + const presetUserName = (viewer.info.email as string) || googleId || twitterId // || threadsId const [loading, setLoading] = useState(enable && !!presetUserName) const [index, setIndex] = useState(0) diff --git a/src/components/Dialogs/SubmitReportDialog/Dialog.tsx b/src/components/Dialogs/SubmitReportDialog/Dialog.tsx index ff04533d55..863c4fdfcf 100644 --- a/src/components/Dialogs/SubmitReportDialog/Dialog.tsx +++ b/src/components/Dialogs/SubmitReportDialog/Dialog.tsx @@ -37,6 +37,12 @@ const Reasons = { id="e3qUqn" /> ), + [ReportReason.CommunityWatchPornAd]: ( + + ), + [ReportReason.CommunityWatchSpamAd]: ( + + ), [ReportReason.Other]: ( ), diff --git a/src/components/Editor/SchedulePublishDialog/SelectDate/index.tsx b/src/components/Editor/SchedulePublishDialog/SelectDate/index.tsx index 1d8f40eb41..a2a7dc9d12 100644 --- a/src/components/Editor/SchedulePublishDialog/SelectDate/index.tsx +++ b/src/components/Editor/SchedulePublishDialog/SelectDate/index.tsx @@ -26,7 +26,10 @@ const SelectDate = ({ onSelect }: SelectDateProps) => { const date = new Date(today) date.setDate(today.getDate() + i) - const value = date.toISOString().split('T')[0] // YYYY-MM-DD format + const yyyy = date.getFullYear() + const mm = String(date.getMonth() + 1).padStart(2, '0') + const dd = String(date.getDate()).padStart(2, '0') + const value = `${yyyy}-${mm}-${dd}` const name = datetimeFormat.absolute.monthDay(date.toISOString(), lang) @@ -44,7 +47,8 @@ const SelectDate = ({ onSelect }: SelectDateProps) => { const generateTimeOptions = () => { const options = [] const now = new Date() - const isToday = selectedDay === now.toISOString().split('T')[0] + const todayValue = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}` + const isToday = selectedDay === todayValue for (let hour = 0; hour < 24; hour++) { for (let minute = 0; minute < 60; minute += 30) { @@ -83,8 +87,8 @@ const SelectDate = ({ onSelect }: SelectDateProps) => { // Create Date object and call onSelect const [hours, minutes] = option.value.split(':').map(Number) - const selectedDate = new Date(selectedDay) - selectedDate.setHours(hours, minutes, 0, 0) + const [year, month, day] = selectedDay.split('-').map(Number) + const selectedDate = new Date(year, month - 1, day, hours, minutes, 0, 0) onSelect(selectedDate) } diff --git a/src/components/Editor/Sidebar/FederationSetting/index.tsx b/src/components/Editor/Sidebar/FederationSetting/index.tsx new file mode 100644 index 0000000000..ef2c858571 --- /dev/null +++ b/src/components/Editor/Sidebar/FederationSetting/index.tsx @@ -0,0 +1,217 @@ +import { useQuery } from '@apollo/client' +import gql from 'graphql-tag' +import { FormattedMessage } from 'react-intl' + +import IconDown from '@/public/static/icons/24px/down.svg' +import { + Button, + Dropdown, + Icon, + Menu, + TextIcon, + toast, + useMutation, +} from '~/components' +import { + EditorViewerFederationSettingQuery, + FederationArticleSettingState, + FederationAuthorSettingState, + SetArticleFederationSettingMutation, + SetArticleFederationSettingMutationVariables, + SetViewerFederationSettingMutation, + SetViewerFederationSettingMutationVariables, +} from '~/gql/graphql' + +import Box from '../Box' + +export type SidebarFederationSettingProps = { + articleId?: string + federationSetting?: FederationArticleSettingState | null + federationSettingSaving?: boolean + editFederationSetting?: (state: FederationArticleSettingState) => void +} + +const VIEWER_FEDERATION_SETTING = gql` + query EditorViewerFederationSetting { + viewer { + id + federationSetting { + state + } + } + } +` + +const SET_ARTICLE_FEDERATION_SETTING = gql` + mutation SetArticleFederationSetting( + $input: SetArticleFederationSettingInput! + ) { + setArticleFederationSetting(input: $input) { + articleId + state + } + } +` + +const SET_VIEWER_FEDERATION_SETTING = gql` + mutation SetViewerFederationSetting( + $input: SetViewerFederationSettingInput! + ) { + setViewerFederationSetting(input: $input) { + userId + state + } + } +` + +const labels = { + [FederationArticleSettingState.Enabled]: ( + + ), + [FederationArticleSettingState.Disabled]: ( + + ), +} + +const toArticleSetting = (state?: FederationAuthorSettingState | null) => + state === FederationAuthorSettingState.Enabled + ? FederationArticleSettingState.Enabled + : FederationArticleSettingState.Disabled + +const toAuthorSetting = (state: FederationArticleSettingState) => + state === FederationArticleSettingState.Enabled + ? FederationAuthorSettingState.Enabled + : FederationAuthorSettingState.Disabled + +const SidebarFederationSetting: React.FC = ({ + articleId, + federationSetting, + federationSettingSaving, + editFederationSetting, +}) => { + const { data, loading: viewerLoading } = + useQuery(VIEWER_FEDERATION_SETTING, { + fetchPolicy: 'cache-and-network', + }) + const [setArticleFederationSetting, { loading }] = useMutation< + SetArticleFederationSettingMutation, + SetArticleFederationSettingMutationVariables + >(SET_ARTICLE_FEDERATION_SETTING) + const [setViewerFederationSetting, { loading: viewerSaving }] = useMutation< + SetViewerFederationSettingMutation, + SetViewerFederationSettingMutationVariables + >(SET_VIEWER_FEDERATION_SETTING) + + const authorDefaultState = toArticleSetting( + data?.viewer?.federationSetting?.state + ) + const currentState = + federationSetting && + federationSetting !== FederationArticleSettingState.Inherit + ? federationSetting + : authorDefaultState + const saving = !!federationSettingSaving || loading || viewerSaving + const disabled = saving || viewerLoading || (!articleId && !data?.viewer?.id) + + const updateSetting = async (state: FederationArticleSettingState) => { + if (state === currentState || disabled) { + return + } + + try { + if (articleId) { + await setArticleFederationSetting({ + variables: { input: { id: articleId, state } }, + optimisticResponse: { + setArticleFederationSetting: { + __typename: 'ArticleFederationSetting', + articleId, + state, + }, + }, + }) + editFederationSetting?.(state) + } else if (data?.viewer?.id) { + const authorState = toAuthorSetting(state) + await setViewerFederationSetting({ + variables: { input: { state: authorState } }, + optimisticResponse: { + setViewerFederationSetting: { + __typename: 'UserFederationSetting', + userId: data.viewer.id, + state: authorState, + }, + }, + }) + } + } catch { + toast.error({ + message: ( + + ), + }) + } + } + + const Content = () => ( + + updateSetting(FederationArticleSettingState.Enabled)} + weight={ + currentState === FederationArticleSettingState.Enabled + ? 'bold' + : 'normal' + } + /> + updateSetting(FederationArticleSettingState.Disabled)} + weight={ + currentState === FederationArticleSettingState.Disabled + ? 'bold' + : 'normal' + } + /> + + ) + + return ( + } + subtitle={ + + } + rightButton={ + }> + {({ openDropdown, ref }) => ( + + )} + + } + /> + ) +} + +export default SidebarFederationSetting diff --git a/src/components/Editor/Sidebar/index.tsx b/src/components/Editor/Sidebar/index.tsx index 61823ef6c4..633a1dbd73 100644 --- a/src/components/Editor/Sidebar/index.tsx +++ b/src/components/Editor/Sidebar/index.tsx @@ -4,6 +4,7 @@ import Circle from './Circle' import Collections from './Collections' import Connections from './Connections' import Cover from './Cover' +import FederationSetting from './FederationSetting' import Indent from './Indent' import ISCN from './ISCN' import License from './License' @@ -15,6 +16,7 @@ import Tags from './Tags' const Sidebar = { CanComment, Cover, + FederationSetting, Tags, Connections, Management, diff --git a/src/components/Layout/GlobalNav/MeMenu/index.tsx b/src/components/Layout/GlobalNav/MeMenu/index.tsx index dfd73b16bd..7b5795625e 100644 --- a/src/components/Layout/GlobalNav/MeMenu/index.tsx +++ b/src/components/Layout/GlobalNav/MeMenu/index.tsx @@ -4,7 +4,8 @@ import baseToast from 'react-hot-toast' import { FormattedMessage } from 'react-intl' import { useAccount, useDisconnect } from 'wagmi' -import IconCircle from '@/public/static/icons/24px/circle.svg' +// FEATURE IS SUNSETTING: circle entry in me menu is hidden +// import IconCircle from '@/public/static/icons/24px/circle.svg' import IconData from '@/public/static/icons/24px/data.svg' import IconDraft from '@/public/static/icons/24px/draft.svg' import IconHistory from '@/public/static/icons/24px/history.svg' @@ -36,13 +37,14 @@ const MeMenu: React.FC = () => { userName: viewer.userName || '', }) - const circle = viewer.ownCircles && viewer.ownCircles[0] - const circlePath = - circle && - toPath({ - page: 'circleDetail', - circle, - }) + // FEATURE IS SUNSETTING: circle entry in me menu is hidden + // const circle = viewer.ownCircles && viewer.ownCircles[0] + // const circlePath = + // circle && + // toPath({ + // page: 'circleDetail', + // circle, + // }) const [logout, { client }] = useMutation( USER_LOGOUT, @@ -114,14 +116,15 @@ const MeMenu: React.FC = () => { is="link" /> - {circlePath && ( + {/* FEATURE IS SUNSETTING: circle entry in me menu is hidden */} + {/* {circlePath && ( } icon={} href={circlePath.href} is="link" /> - )} + )} */} } diff --git a/src/components/Layout/Header/MeButton/SideDrawerNav/DrawerContent/MeMenu/index.tsx b/src/components/Layout/Header/MeButton/SideDrawerNav/DrawerContent/MeMenu/index.tsx index b006a80101..d15474f60a 100644 --- a/src/components/Layout/Header/MeButton/SideDrawerNav/DrawerContent/MeMenu/index.tsx +++ b/src/components/Layout/Header/MeButton/SideDrawerNav/DrawerContent/MeMenu/index.tsx @@ -4,7 +4,8 @@ import baseToast from 'react-hot-toast' import { FormattedMessage } from 'react-intl' import { useAccount, useDisconnect } from 'wagmi' -import IconCircle from '@/public/static/icons/24px/circle.svg' +// FEATURE IS SUNSETTING: circle entry in side drawer me menu is hidden +// import IconCircle from '@/public/static/icons/24px/circle.svg' import IconData from '@/public/static/icons/24px/data.svg' import IconDraft from '@/public/static/icons/24px/draft.svg' import IconHistory from '@/public/static/icons/24px/history.svg' @@ -39,13 +40,14 @@ const Top: React.FC = () => { userName: viewer.userName || '', }) - const circle = viewer.ownCircles && viewer.ownCircles[0] - const circlePath = - circle && - toPath({ - page: 'circleDetail', - circle, - }) + // FEATURE IS SUNSETTING: circle entry in side drawer me menu is hidden + // const circle = viewer.ownCircles && viewer.ownCircles[0] + // const circlePath = + // circle && + // toPath({ + // page: 'circleDetail', + // circle, + // }) return ( @@ -81,7 +83,8 @@ const Top: React.FC = () => { is="link" /> - {circlePath && ( + {/* FEATURE IS SUNSETTING: circle entry in side drawer me menu is hidden */} + {/* {circlePath && ( } @@ -89,7 +92,7 @@ const Top: React.FC = () => { href={circlePath.href} is="link" /> - )} + )} */} { + onTabClick('hottest_moments')} + > + + + + + + + {channels.map((c) => ( ))} diff --git a/src/components/Layout/index.tsx b/src/components/Layout/index.tsx index 9d0302de4a..0672842387 100644 --- a/src/components/Layout/index.tsx +++ b/src/components/Layout/index.tsx @@ -72,6 +72,7 @@ const useLayoutType = () => { isInPath('ME_SETTINGS_NOTIFICATIONS_CIRCLE') || isInPath('ME_SETTINGS_MISC') || isInPath('ME_SETTINGS_BLOCKED') || + isInPath('ME_SETTINGS_PERSONHOOD_FEASIBILITY') || isInPath('ME_DRAFT_DETAIL') || // Moment isInPath('MOMENT_DETAIL') || @@ -95,6 +96,7 @@ const useLayoutType = () => { isHome || isInPath('FEATURED') || isInPath('HOTTEST') || + isInPath('HOTTEST_MOMENTS') || isInPath('NEWEST') || isInPath('CHANNEL') || isInPath('FOLLOW') || diff --git a/src/components/MomentDigest/DropdownActions/RevokeMomentFeed.tsx b/src/components/MomentDigest/DropdownActions/RevokeMomentFeed.tsx new file mode 100644 index 0000000000..fbdd40e963 --- /dev/null +++ b/src/components/MomentDigest/DropdownActions/RevokeMomentFeed.tsx @@ -0,0 +1,35 @@ +import gql from 'graphql-tag' + +import IconCircleMinus from '@/public/static/icons/24px/circle-minus.svg' +import { Icon, Menu, toast, useMutation } from '~/components' +import { RevokeMomentFeedMutation } from '~/gql/graphql' + +const REVOKE_MOMENT_FEED = gql` + mutation RevokeMomentFeed($id: ID!) { + updateMomentFeedApplicationState(input: { id: $id, state: revoked }) { + id + isMomentFeedApplied + } + } +` + +const RevokeMomentFeed = ({ userId }: { userId: string }) => { + const [revoke] = useMutation(REVOKE_MOMENT_FEED, { + variables: { id: userId }, + }) + + return ( + } + onClick={async () => { + try { + await revoke() + toast.success({ message: '已撤銷資格' }) + } catch {} + }} + /> + ) +} + +export default RevokeMomentFeed diff --git a/src/components/MomentDigest/DropdownActions/ToggleAdMoment.tsx b/src/components/MomentDigest/DropdownActions/ToggleAdMoment.tsx new file mode 100644 index 0000000000..f261af2682 --- /dev/null +++ b/src/components/MomentDigest/DropdownActions/ToggleAdMoment.tsx @@ -0,0 +1,82 @@ +import { useQuery } from '@apollo/client' +import gql from 'graphql-tag' + +import IconPin from '@/public/static/icons/24px/pin.svg' +import IconUnpin from '@/public/static/icons/24px/unpin.svg' +import { Icon, Menu, Spinner, toast, useMutation } from '~/components' +import { FetchMomentAdStatusQuery, ToggleAdMomentMutation } from '~/gql/graphql' + +const fragments = { + moment: gql` + fragment ToggleAdMomentMoment on Moment { + id + adStatus { + isAd + } + } + `, +} + +const TOGGLE_AD_MOMENT = gql` + mutation ToggleAdMoment($momentId: ID!, $isAd: Boolean!) { + setWritingAdStatus(input: { id: $momentId, isAd: $isAd }) { + ... on Moment { + id + ...ToggleAdMomentMoment + } + } + } + ${fragments.moment} +` + +const FETCH_MOMENT_AD_STATUS = gql` + query FetchMomentAdStatus($shortHash: String!) { + moment(input: { shortHash: $shortHash }) { + ...ToggleAdMomentMoment + } + } + ${fragments.moment} +` + +const ToggleAdMoment = ({ shortHash }: { shortHash: string }) => { + const { data, loading } = useQuery( + FETCH_MOMENT_AD_STATUS, + { + variables: { + shortHash, + }, + } + ) + + const isAd = data?.moment?.adStatus.isAd + const momentId = data?.moment?.id + + const [update] = useMutation(TOGGLE_AD_MOMENT, { + variables: { + momentId, + isAd: !isAd, + }, + }) + + if (loading) { + return + } + + return ( + } + onClick={async () => { + await update() + + toast.success({ + message: isAd ? '已取消標記廣告' : '已標記廣告', + }) + }} + /> + ) +} + +ToggleAdMoment.fragments = fragments + +export default ToggleAdMoment diff --git a/src/components/MomentDigest/DropdownActions/index.tsx b/src/components/MomentDigest/DropdownActions/index.tsx index 7fe7c107ef..bcc1614ddb 100644 --- a/src/components/MomentDigest/DropdownActions/index.tsx +++ b/src/components/MomentDigest/DropdownActions/index.tsx @@ -20,6 +20,15 @@ import { } from '~/components' import { SubmitReportDialogProps } from '~/components/Dialogs/SubmitReportDialog/Dialog' import { MomentDigestDropdownActionsMomentFragment } from '~/gql/graphql' +import { ArchiveUserDialogProps } from '~/views/User/UserProfile/DropdownActions/ArchiveUser/Dialog' +import { + OpenToggleFreezeUserDialogWithProps, + ToggleFreezeUserDialogProps, +} from '~/views/User/UserProfile/DropdownActions/ToggleFreezeUser/Dialog' +import { + OpenToggleRestrictUserDialogWithProps, + ToggleRestrictUserDialogProps, +} from '~/views/User/UserProfile/DropdownActions/ToggleRestrictUser/Dialog' import DeleteMoment from './DeleteMoment' import { DeleteMomentDialogProps } from './DeleteMoment/Dialog' @@ -33,10 +42,55 @@ const DynamicToggleSpamButton = dynamic( } ) +const DynamicToggleAdMomentButton = dynamic(() => import('./ToggleAdMoment'), { + loading: () => , +}) + +const DynamicRevokeMomentFeedButton = dynamic( + () => import('./RevokeMomentFeed'), + { + loading: () => , + } +) + +const DynamicToggleFreezeUserButton = dynamic( + () => + import('~/views/User/UserProfile/DropdownActions/ToggleFreezeUser/Button'), + { loading: () => } +) +const DynamicToggleFreezeUserDialog = dynamic( + () => + import('~/views/User/UserProfile/DropdownActions/ToggleFreezeUser/Dialog'), + { loading: () => } +) +const DynamicToggleRestrictUserButton = dynamic( + () => + import( + '~/views/User/UserProfile/DropdownActions/ToggleRestrictUser/Button' + ), + { loading: () => } +) +const DynamicToggleRestrictUserDialog = dynamic( + () => + import( + '~/views/User/UserProfile/DropdownActions/ToggleRestrictUser/Dialog' + ), + { loading: () => } +) +const DynamicArchiveUserButton = dynamic( + () => import('~/views/User/UserProfile/DropdownActions/ArchiveUser/Button'), + { loading: () => } +) +const DynamicArchiveUserDialog = dynamic( + () => import('~/views/User/UserProfile/DropdownActions/ArchiveUser/Dialog'), + { loading: () => } +) + const fragments = { moment: gql` fragment MomentDigestDropdownActionsMoment on Moment { id + shortHash state author { id @@ -58,6 +112,13 @@ interface Controls { interface DialogProps { openDeleteMomentDialog: () => void openSubmitReportDialog: () => void + openToggleRestrictUserDialog: ( + props: OpenToggleRestrictUserDialogWithProps + ) => void + openToggleFreezeUserDialog: ( + props: OpenToggleFreezeUserDialogWithProps + ) => void + openArchiveUserDialog: () => void } type BaseDropdownActionsProps = DropdownActionsProps & Controls & DialogProps @@ -69,6 +130,9 @@ const BaseDropdownActions = ({ openDeleteMomentDialog, openSubmitReportDialog, + openToggleRestrictUserDialog, + openToggleFreezeUserDialog, + openArchiveUserDialog, }: BaseDropdownActionsProps) => { const viewer = useContext(ViewerContext) @@ -84,6 +148,17 @@ const BaseDropdownActions = ({ <> + + + + + )} @@ -164,7 +239,44 @@ const DropdownActions = (props: DropdownActionsProps) => { } }) - return + const WithToggleRestrictUser = withDialog< + Omit + >( + WithDeleteMoment, + DynamicToggleRestrictUserDialog as React.ComponentType< + Omit & { + children: (props: { openDialog: () => void }) => React.ReactNode + } + >, + { id: moment.author.id, userName: moment.author.userName! }, + ({ openDialog }) => ({ + openToggleRestrictUserDialog: openDialog, + }) + ) + + const WithToggleFreezeUser = withDialog< + Omit + >( + WithToggleRestrictUser, + DynamicToggleFreezeUserDialog as React.ComponentType< + Omit & { + children: (props: { openDialog: () => void }) => React.ReactNode + } + >, + { id: moment.author.id, userName: moment.author.userName! }, + ({ openDialog }) => ({ + openToggleFreezeUserDialog: openDialog, + }) + ) + + const WithArchiveUser = withDialog>( + WithToggleFreezeUser, + DynamicArchiveUserDialog, + { id: moment.author.id, userName: moment.author.userName! }, + ({ openDialog }) => ({ openArchiveUserDialog: openDialog }) + ) + + return } DropdownActions.fragments = fragments diff --git a/src/components/Notice/CommentCard/index.tsx b/src/components/Notice/CommentCard/index.tsx index dc74bb54e7..b3ef13e465 100644 --- a/src/components/Notice/CommentCard/index.tsx +++ b/src/components/Notice/CommentCard/index.tsx @@ -87,6 +87,25 @@ const CommentCard = ({ return null } + if (comment.author?.status?.state === 'archived' && (moment || article)) { + const content = moment + ? intl.formatMessage({ + defaultMessage: 'Comment deleted', + description: 'src/components/Notice/NoticeComment.tsx/moment', + id: 'Ci7dxf', + }) + : intl.formatMessage({ + defaultMessage: 'Comment deleted', + description: 'src/components/Notice/NoticeComment.tsx/article', + id: '7zn5ig', + }) + return ( +
+ +
+ ) + } + if (comment.state === 'archived' && moment) { return (
diff --git a/src/components/Notice/UserNotice/MomentFeedApproved.tsx b/src/components/Notice/UserNotice/MomentFeedApproved.tsx new file mode 100644 index 0000000000..4ad0286e81 --- /dev/null +++ b/src/components/Notice/UserNotice/MomentFeedApproved.tsx @@ -0,0 +1,49 @@ +import gql from 'graphql-tag' +import Link from 'next/link' +import { FormattedMessage } from 'react-intl' + +import { PATHS } from '~/common/enums' +import { MomentFeedApprovedFragment } from '~/gql/graphql' + +import NoticeCard from '../NoticeCard' +import NoticeDate from '../NoticeDate' +import officialStyles from '../OfficialNotice/styles.module.css' + +const MomentFeedApproved = ({ + notice, +}: { + notice: MomentFeedApprovedFragment +}) => { + const Message = () => ( +

+ +

+ ) + + return ( + + + + } + /> + ) +} + +MomentFeedApproved.fragments = { + notice: gql` + fragment MomentFeedApproved on UserNotice { + id + ...NoticeDate + } + ${NoticeDate.fragments.notice} + `, +} + +export default MomentFeedApproved diff --git a/src/components/Notice/UserNotice/index.tsx b/src/components/Notice/UserNotice/index.tsx index 7b9ef667ec..116cb72be0 100644 --- a/src/components/Notice/UserNotice/index.tsx +++ b/src/components/Notice/UserNotice/index.tsx @@ -2,12 +2,15 @@ import gql from 'graphql-tag' import { UserNoticeFragment } from '~/gql/graphql' +import MomentFeedApproved from './MomentFeedApproved' import UserNewFollower from './UserNewFollower' const UserNotice = ({ notice }: { notice: UserNoticeFragment }) => { switch (notice.userNoticeType) { case 'UserNewFollower': return + case 'MomentFeedApproved': + return default: return null } @@ -21,8 +24,10 @@ UserNotice.fragments = { __typename userNoticeType: type ...UserNewFollower + ...MomentFeedApproved } ${UserNewFollower.fragments.notice} + ${MomentFeedApproved.fragments.notice} `, } diff --git a/src/pages/[name]/analytics.tsx b/src/pages/[name]/analytics.tsx index 7bd31046f4..dacef19288 100644 --- a/src/pages/[name]/analytics.tsx +++ b/src/pages/[name]/analytics.tsx @@ -1,16 +1,17 @@ -import { EmptyLayout, Protected, Throw404, useRoute } from '~/components' -import CircleAnalytics from '~/views/Circle/Analytics' +import { EmptyLayout, Throw404 } from '~/components' +// import { Protected, useRoute } from '~/components' +// import CircleAnalytics from '~/views/Circle/Analytics' const NameCircleAnalytics = () => { - const { isPathStartWith } = useRoute() - - if (isPathStartWith('/~', true)) { - return ( - - - - ) - } + // const { isPathStartWith } = useRoute() + // + // if (isPathStartWith('/~', true)) { + // return ( + // + // + // + // ) + // } return ( diff --git a/src/pages/[name]/broadcast.tsx b/src/pages/[name]/broadcast.tsx index 53908cd031..247133a538 100644 --- a/src/pages/[name]/broadcast.tsx +++ b/src/pages/[name]/broadcast.tsx @@ -1,3 +1,11 @@ -import CircleBroadcast from '~/views/Circle/Broadcast' +import { EmptyLayout, Throw404 } from '~/components' +// import CircleBroadcast from '~/views/Circle/Broadcast' -export default CircleBroadcast +const NameBroadcast = () => ( + + + +) + +export default NameBroadcast +// export default CircleBroadcast diff --git a/src/pages/[name]/discussion.tsx b/src/pages/[name]/discussion.tsx index b6e465aed0..ff3eb974fd 100644 --- a/src/pages/[name]/discussion.tsx +++ b/src/pages/[name]/discussion.tsx @@ -1,3 +1,11 @@ -import CircleDiscussion from '~/views/Circle/Discussion' +import { EmptyLayout, Throw404 } from '~/components' +// import CircleDiscussion from '~/views/Circle/Discussion' -export default CircleDiscussion +const NameDiscussion = () => ( + + + +) + +export default NameDiscussion +// export default CircleDiscussion diff --git a/src/pages/[name]/index.tsx b/src/pages/[name]/index.tsx index 212ad42ce8..0b58803b82 100644 --- a/src/pages/[name]/index.tsx +++ b/src/pages/[name]/index.tsx @@ -1,5 +1,5 @@ import { EmptyLayout, Throw404, useRoute } from '~/components' -import CircleWorks from '~/views/Circle/Works' +// import CircleWorks from '~/views/Circle/Works' import UserWritings from '~/views/User/Writings' const NameIndex = () => { @@ -7,9 +7,10 @@ const NameIndex = () => { if (isPathStartWith('/@', true)) { return - } else if (isPathStartWith('/~', true)) { - return } + // else if (isPathStartWith('/~', true)) { + // return + // } return ( diff --git a/src/pages/[name]/settings/edit-profile.tsx b/src/pages/[name]/settings/edit-profile.tsx index ac5632ffb4..aabc0c4dfa 100644 --- a/src/pages/[name]/settings/edit-profile.tsx +++ b/src/pages/[name]/settings/edit-profile.tsx @@ -1,16 +1,17 @@ -import { EmptyLayout, Protected, Throw404, useRoute } from '~/components' -import CircleSettingsEditProfile from '~/views/Circle/Settings/EditProfile' +import { EmptyLayout, Throw404 } from '~/components' +// import { Protected, useRoute } from '~/components' +// import CircleSettingsEditProfile from '~/views/Circle/Settings/EditProfile' const NameSettingsEditProfile = () => { - const { isPathStartWith } = useRoute() - - if (isPathStartWith('/~', true)) { - return ( - - - - ) - } + // const { isPathStartWith } = useRoute() + // + // if (isPathStartWith('/~', true)) { + // return ( + // + // + // + // ) + // } return ( diff --git a/src/pages/[name]/settings/index.tsx b/src/pages/[name]/settings/index.tsx index 28efa0b625..fccb8b458b 100644 --- a/src/pages/[name]/settings/index.tsx +++ b/src/pages/[name]/settings/index.tsx @@ -1,16 +1,17 @@ -import { EmptyLayout, Protected, Throw404, useRoute } from '~/components' -import CircleSettings from '~/views/Circle/Settings' +import { EmptyLayout, Throw404 } from '~/components' +// import { Protected, useRoute } from '~/components' +// import CircleSettings from '~/views/Circle/Settings' const NameSettings = () => { - const { isPathStartWith } = useRoute() - - if (isPathStartWith('/~', true)) { - return ( - - - - ) - } + // const { isPathStartWith } = useRoute() + // + // if (isPathStartWith('/~', true)) { + // return ( + // + // + // + // ) + // } return ( diff --git a/src/pages/[name]/settings/manage-invitation.tsx b/src/pages/[name]/settings/manage-invitation.tsx index 4d61d07545..6027b45d6e 100644 --- a/src/pages/[name]/settings/manage-invitation.tsx +++ b/src/pages/[name]/settings/manage-invitation.tsx @@ -1,16 +1,17 @@ -import { EmptyLayout, Protected, Throw404, useRoute } from '~/components' -import CircleSettingsManageInvitation from '~/views/Circle/Settings/ManageInvitation' +import { EmptyLayout, Throw404 } from '~/components' +// import { Protected, useRoute } from '~/components' +// import CircleSettingsManageInvitation from '~/views/Circle/Settings/ManageInvitation' const NameSettingsManageInvitation = () => { - const { isPathStartWith } = useRoute() - - if (isPathStartWith('/~', true)) { - return ( - - - - ) - } + // const { isPathStartWith } = useRoute() + // + // if (isPathStartWith('/~', true)) { + // return ( + // + // + // + // ) + // } return ( diff --git a/src/pages/api/personhood/prover.ts b/src/pages/api/personhood/prover.ts new file mode 100644 index 0000000000..c368265d8d --- /dev/null +++ b/src/pages/api/personhood/prover.ts @@ -0,0 +1,956 @@ +import type { NextApiRequest, NextApiResponse } from 'next' + +const HANDOFF_STORAGE_KEY = 'matters.personhood.browserProofHandoff.v1' +const RUN_STATE_STORAGE_KEY = 'matters.personhood.browserProofRunState.v1' +const HANDOFF_FRAGMENT_KEY = 'personhood_handoff' +const PROVER_ASSET_BASE = '/api/personhood/prover/assets' + +const handler = (_req: NextApiRequest, res: NextApiResponse) => { + res.setHeader('Content-Type', 'text/html; charset=utf-8') + res.setHeader('Cache-Control', 'no-store') + res.setHeader('Cross-Origin-Opener-Policy', 'same-origin') + res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp') + res.setHeader('Cross-Origin-Resource-Policy', 'same-origin') + res.setHeader('Referrer-Policy', 'no-referrer') + res.setHeader('X-Content-Type-Options', 'nosniff') + res.setHeader( + 'Content-Security-Policy', + [ + "default-src 'self'", + "base-uri 'none'", + "form-action 'none'", + "frame-ancestors 'none'", + "img-src 'none'", + "connect-src 'self'", + "style-src 'unsafe-inline'", + "script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' blob:", + "worker-src 'self' blob:", + ].join('; ') + ) + + res.status(200).send(getHtml()) +} + +export default handler + +const getHtml = () => ` + + + + + + Personhood prover + + + +
+

Personhood prover

+

Isolated browser container for the zkID prover.

+
+ +
+

Readiness

+
+
+ +
+

Handoff

+
+

+ + + Back +
+ +
+

Report

+
{}
+
+ + + +` diff --git a/src/pages/api/personhood/prover/assets/[[...asset]].ts b/src/pages/api/personhood/prover/assets/[[...asset]].ts new file mode 100644 index 0000000000..0f92dfdf9b --- /dev/null +++ b/src/pages/api/personhood/prover/assets/[[...asset]].ts @@ -0,0 +1,159 @@ +import type { NextApiRequest, NextApiResponse } from 'next' +import { Readable } from 'stream' +import type { ReadableStream as NodeReadableStream } from 'stream/web' +import { gunzipSync } from 'zlib' + +type ProverAsset = { + contentType: string + patch?: (body: Buffer) => Buffer + url: string + gzip?: boolean + passthrough?: boolean +} + +const ZKID_RELEASE = 'https://github.com/zkmopro/zkID/releases/download/latest' + +const ASSETS: Record = { + 'spartan2_wasm.js': { + contentType: 'application/javascript; charset=utf-8', + gzip: true, + patch: patchSpartan2WasmJs, + url: `${ZKID_RELEASE}/spartan2_wasm.js.gz`, + }, + 'spartan2_wasm_bg.wasm': { + contentType: 'application/wasm', + gzip: true, + url: `${ZKID_RELEASE}/spartan2_wasm_bg.wasm.gz`, + }, + 'moica-g3.cer': { + contentType: 'application/pkix-cert', + url: 'https://moica.nat.gov.tw/repository/Certs/MOICA-G3.cer', + }, + 'witness_calculator.js': { + contentType: 'application/javascript; charset=utf-8', + gzip: true, + url: `${ZKID_RELEASE}/witness_calculator.js.gz`, + }, + 'cert_chain_rs4096_proving.key.gz': { + contentType: 'application/gzip', + passthrough: true, + url: `${ZKID_RELEASE}/cert_chain_rs4096_proving.key.gz`, + }, + 'user_sig_rs2048_proving.key.gz': { + contentType: 'application/gzip', + passthrough: true, + url: `${ZKID_RELEASE}/user_sig_rs2048_proving.key.gz`, + }, + 'certChainRS4096.wasm.gz': { + contentType: 'application/gzip', + passthrough: true, + url: `${ZKID_RELEASE}/certChainRS4096.wasm.gz`, + }, + 'userSigRS2048.wasm.gz': { + contentType: 'application/gzip', + passthrough: true, + url: `${ZKID_RELEASE}/userSigRS2048.wasm.gz`, + }, +} + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + const path = Array.isArray(req.query.asset) + ? req.query.asset.join('/') + : req.query.asset + + if (!path) { + sendJs(res, 'export * from "./spartan2_wasm.js";\n') + return + } + + const asset = ASSETS[path] + if (!asset) { + res.status(404).json({ error: 'asset_not_found' }) + return + } + + try { + const headers: HeadersInit = { Accept: '*/*' } + const range = req.headers.range + if (typeof range === 'string') { + headers.Range = range + } + + const upstream = await fetch(asset.url, { + headers, + }) + + if (!upstream.ok) { + res.status(upstream.status).json({ + error: 'asset_fetch_failed', + status: upstream.status, + statusText: upstream.statusText, + }) + return + } + + if (asset.passthrough) { + sendAssetHeaders(res, asset.contentType, upstream.headers) + res.status(upstream.status) + if (req.method === 'HEAD') { + res.end() + return + } + if (!upstream.body) { + res.end() + return + } + Readable.fromWeb(upstream.body as unknown as NodeReadableStream).pipe(res) + return + } + + const raw = Buffer.from(await upstream.arrayBuffer()) + const source = asset.passthrough ? raw : asset.gzip ? gunzipSync(raw) : raw + const body = asset.patch ? asset.patch(source) : source + + sendAssetHeaders(res, asset.contentType, upstream.headers) + res.status(200).send(body) + } catch (error) { + res.status(502).json({ + error: 'asset_proxy_failed', + message: error instanceof Error ? error.message : String(error), + }) + } +} + +export default handler + +function sendJs(res: NextApiResponse, source: string) { + sendAssetHeaders(res, 'application/javascript; charset=utf-8') + res.status(200).send(source) +} + +function sendAssetHeaders( + res: NextApiResponse, + contentType: string, + upstreamHeaders?: Headers +) { + const contentLength = upstreamHeaders?.get('content-length') + if (contentLength) { + res.setHeader('Content-Length', contentLength) + } + res.setHeader('Content-Type', contentType) + res.setHeader('Cache-Control', 'public, max-age=14400, immutable') + res.setHeader('Cross-Origin-Resource-Policy', 'same-origin') + res.setHeader('X-Content-Type-Options', 'nosniff') +} + +function patchSpartan2WasmJs(body: Buffer) { + const source = body.toString('utf8') + return Buffer.from( + source.replace( + "import { startWorkers } from './snippets/wasm-bindgen-rayon-38edf6e439f6d70d/src/workerHelpers.js';", + [ + 'async function startWorkers() {', + " throw new Error('Threaded proving is disabled in the Matters browser preflight build.');", + '}', + ].join('\n') + ), + 'utf8' + ) +} diff --git a/src/pages/api/personhood/tw-fido/result.ts b/src/pages/api/personhood/tw-fido/result.ts new file mode 100644 index 0000000000..bcc1262d84 --- /dev/null +++ b/src/pages/api/personhood/tw-fido/result.ts @@ -0,0 +1,176 @@ +import type { NextApiRequest, NextApiResponse } from 'next' + +import { + assertTwFidoEnabled, + decodeSpTicket, + getSignResult, + getTwFidoConfig, + isPendingSignResult, +} from '~/server/personhood/twFido' + +type ResultBody = { + appId?: unknown + challenge?: unknown + challengeExpiresAt?: unknown + spTicket?: unknown +} + +type ResultResponse = + | { + cert?: string + certSize: number + proofInput?: { + appId: string + cert: string + challenge: string + challengeExpiresAt?: string + signedResponse: string + } + signedResponse?: string + signedResponseSize: number + status: 'signed' + } + | { + errorCode: string + message?: string + status: 'pending' + } + | { + error: string + } + +const handler = async ( + req: NextApiRequest, + res: NextApiResponse +) => { + if (req.method !== 'POST') { + res.setHeader('Allow', 'POST') + res.status(405).json({ error: 'method_not_allowed' }) + return + } + + if (!isSameOriginRequest(req)) { + res.status(403).json({ error: 'forbidden_origin' }) + return + } + + try { + assertTwFidoEnabled() + + const body = parseBody(req.body) + if (typeof body.spTicket !== 'string') { + res.status(400).json({ error: 'missing_sp_ticket' }) + return + } + + const config = getTwFidoConfig() + const ticketPayload = decodeSpTicket(body.spTicket) + const result = await getSignResult({ config, ticketPayload }) + + if (result.error_code !== '0') { + if (isPendingSignResult(result.error_code)) { + res.status(200).json({ + errorCode: result.error_code, + message: result.error_message, + status: 'pending', + }) + return + } + + res.status(502).json({ + error: `getAthOrSignResult failed: ${result.error_code} ${ + result.error_message || '' + }`.trim(), + }) + return + } + + const cert = result.result?.cert + const signedResponse = result.result?.signed_response + if (!cert || !signedResponse) { + res.status(502).json({ error: 'missing_sign_result_payload' }) + return + } + + res.status(200).json({ + cert: config.returnProofInput ? cert : undefined, + certSize: Buffer.byteLength(cert, 'base64'), + proofInput: config.returnProofInput + ? createProofInput({ + appId: body.appId, + cert, + challenge: body.challenge, + challengeExpiresAt: body.challengeExpiresAt, + signedResponse, + }) + : undefined, + signedResponse: config.returnProofInput ? signedResponse : undefined, + signedResponseSize: Buffer.byteLength(signedResponse, 'base64'), + status: 'signed', + }) + } catch (error) { + const message = error instanceof Error ? error.message : 'unknown_error' + res + .status(message === 'personhood_tw_fido_disabled' ? 404 : 500) + .json({ error: message }) + } +} + +export default handler + +const parseBody = (body: unknown): ResultBody => { + if (typeof body === 'string') { + return JSON.parse(body) as ResultBody + } + return (body || {}) as ResultBody +} + +const createProofInput = ({ + appId, + cert, + challenge, + challengeExpiresAt, + signedResponse, +}: { + appId: unknown + cert: string + challenge: unknown + challengeExpiresAt: unknown + signedResponse: string +}) => { + if ( + typeof appId !== 'string' || + typeof challenge !== 'string' || + !appId || + !challenge + ) { + return undefined + } + + return { + appId, + cert, + challenge, + challengeExpiresAt: + typeof challengeExpiresAt === 'string' ? challengeExpiresAt : undefined, + signedResponse, + } +} + +const isSameOriginRequest = (req: NextApiRequest) => { + const origin = req.headers.origin + if (!origin) { + return true + } + + const host = req.headers.host + if (!host) { + return false + } + + try { + return new URL(origin).host === host + } catch { + return false + } +} diff --git a/src/pages/api/personhood/tw-fido/sp-ticket.ts b/src/pages/api/personhood/tw-fido/sp-ticket.ts new file mode 100644 index 0000000000..1119a491a8 --- /dev/null +++ b/src/pages/api/personhood/tw-fido/sp-ticket.ts @@ -0,0 +1,136 @@ +import type { NextApiRequest, NextApiResponse } from 'next' + +import { + assertTwFidoEnabled, + buildTwFidoDeeplink, + createPersonhoodChallenge, + createSpTicket, + getTwFidoConfig, + normalizeTwFidoIdNum, +} from '~/server/personhood/twFido' + +type SpTicketBody = { + idNum?: unknown + returnUrl?: unknown +} + +type SpTicketResponse = + | { + appId: string + apiBaseUrl: string + challenge: string + challengeExpiresAt?: string + deeplink: string + expiresAt?: string + signType: string + spTicket: string + spTicketId: string + status: 'ticket_created' + transactionId: string + } + | { + error: string + } + +const handler = async ( + req: NextApiRequest, + res: NextApiResponse +) => { + if (req.method !== 'POST') { + res.setHeader('Allow', 'POST') + res.status(405).json({ error: 'method_not_allowed' }) + return + } + + if (!isSameOriginRequest(req)) { + res.status(403).json({ error: 'forbidden_origin' }) + return + } + + try { + assertTwFidoEnabled() + + const body = parseBody(req.body) + const idNum = normalizeTwFidoIdNum(body.idNum) + if (!idNum) { + res.status(400).json({ error: 'invalid_id_num' }) + return + } + + const returnUrl = + typeof body.returnUrl === 'string' && isAllowedReturnUrl(body.returnUrl) + ? body.returnUrl + : `https://${req.headers.host}/me/settings/personhood/feasibility` + + const config = getTwFidoConfig() + const challenge = await createPersonhoodChallenge(config) + const ticket = await createSpTicket({ + appId: challenge.appId, + config, + idNum, + }) + const deeplink = buildTwFidoDeeplink({ + returnUrl, + spTicket: ticket.spTicket, + }) + + res.status(200).json({ + apiBaseUrl: config.apiBaseUrl, + appId: challenge.appId, + challenge: challenge.challenge, + challengeExpiresAt: challenge.expiresAt, + deeplink, + expiresAt: ticket.ticketPayload.expiration_time, + signType: config.signType, + spTicket: ticket.spTicket, + spTicketId: ticket.ticketPayload.sp_ticket_id, + status: 'ticket_created', + transactionId: ticket.ticketPayload.transaction_id, + }) + } catch (error) { + const message = error instanceof Error ? error.message : 'unknown_error' + res + .status(message === 'personhood_tw_fido_disabled' ? 404 : 500) + .json({ error: message }) + } +} + +export default handler + +const parseBody = (body: unknown): SpTicketBody => { + if (typeof body === 'string') { + return JSON.parse(body) as SpTicketBody + } + return (body || {}) as SpTicketBody +} + +const isAllowedReturnUrl = (value: string) => { + try { + const url = new URL(value) + return ( + url.protocol === 'https:' || + (url.protocol === 'http:' && + (url.hostname === 'localhost' || url.hostname === '127.0.0.1')) + ) + } catch { + return false + } +} + +const isSameOriginRequest = (req: NextApiRequest) => { + const origin = req.headers.origin + if (!origin) { + return true + } + + const host = req.headers.host + if (!host) { + return false + } + + try { + return new URL(origin).host === host + } catch { + return false + } +} diff --git a/src/pages/api/personhood/zkid/handoff.ts b/src/pages/api/personhood/zkid/handoff.ts new file mode 100644 index 0000000000..b7b3ff65f5 --- /dev/null +++ b/src/pages/api/personhood/zkid/handoff.ts @@ -0,0 +1,115 @@ +import type { NextApiRequest, NextApiResponse } from 'next' + +import { requestMattersGraphQL } from '~/server/personhood/mattersGraphql' +import { assertTwFidoEnabled } from '~/server/personhood/twFido' + +type HandoffBody = { + challenge?: unknown + challengeExpiresAt?: unknown +} + +type HandoffResponse = + | { + expiresAt: string + status: 'handoff_created' + token: string + } + | { + error: string + } + +type CreatePersonhoodHandoffData = { + createPersonhoodHandoff: { + expiresAt: string + token: string + } +} + +const CREATE_PERSONHOOD_HANDOFF = [ + 'mutation CreatePersonhoodHandoff($input: CreatePersonhoodHandoffInput!) {', + ` + createPersonhoodHandoff(input: $input) { + expiresAt + token + } + `, + '}', +].join('\n') + +const handler = async ( + req: NextApiRequest, + res: NextApiResponse +) => { + if (req.method !== 'POST') { + res.setHeader('Allow', 'POST') + res.status(405).json({ error: 'method_not_allowed' }) + return + } + + if (!isSameOriginRequest(req)) { + res.status(403).json({ error: 'forbidden_origin' }) + return + } + + try { + assertTwFidoEnabled() + + const body = parseBody(req.body) + if (typeof body.challenge !== 'string' || !body.challenge) { + res.status(400).json({ error: 'missing_challenge' }) + return + } + + const data = await requestMattersGraphQL({ + cookie: req.headers.cookie, + query: CREATE_PERSONHOOD_HANDOFF, + variables: { + input: { + challenge: body.challenge, + challengeExpiresAt: + typeof body.challengeExpiresAt === 'string' + ? body.challengeExpiresAt + : undefined, + }, + }, + }) + + res.status(200).json({ + expiresAt: data.createPersonhoodHandoff.expiresAt, + status: 'handoff_created', + token: data.createPersonhoodHandoff.token, + }) + } catch (error) { + const message = error instanceof Error ? error.message : 'unknown_error' + res + .status(message === 'personhood_tw_fido_disabled' ? 404 : 500) + .json({ error: message }) + } +} + +export default handler + +const parseBody = (body: unknown): HandoffBody => { + if (typeof body === 'string') { + return JSON.parse(body) as HandoffBody + } + return (body || {}) as HandoffBody +} + +const isSameOriginRequest = (req: NextApiRequest) => { + const origin = req.headers.origin + if (!origin) { + return true + } + + const host = req.headers.host + if (!host) { + return false + } + + try { + return new URL(origin).host === host + } catch { + return false + } +} diff --git a/src/pages/api/personhood/zkid/link-verify.ts b/src/pages/api/personhood/zkid/link-verify.ts new file mode 100644 index 0000000000..4df835d0d5 --- /dev/null +++ b/src/pages/api/personhood/zkid/link-verify.ts @@ -0,0 +1,141 @@ +import type { NextApiRequest, NextApiResponse } from 'next' + +import { requestMattersGraphQL } from '~/server/personhood/mattersGraphql' +import { assertTwFidoEnabled } from '~/server/personhood/twFido' + +type LinkVerifyBody = { + cert_chain_proof?: unknown + cert_chain_type?: unknown + certChainProof?: unknown + certChainType?: unknown + handoff_token?: unknown + handoffToken?: unknown + user_sig_proof?: unknown + userSigProof?: unknown +} + +type LinkVerifyResponse = + | Record + | { + error: string + } + +type ClaimPersonhoodBadgeData = { + claimPersonhoodBadge: { + id: string + info: { + badges: Array<{ type: string }> + } + } +} + +const CLAIM_PERSONHOOD_BADGE = [ + 'mutation ClaimPersonhoodBadge($input: ClaimPersonhoodBadgeInput!) {', + ` + claimPersonhoodBadge(input: $input) { + id + info { + badges { + type + } + } + } + `, + '}', +].join('\n') + +export const config = { + api: { + bodyParser: { + sizeLimit: '2mb', + }, + }, +} + +const handler = async ( + req: NextApiRequest, + res: NextApiResponse +) => { + if (req.method !== 'POST') { + res.setHeader('Allow', 'POST') + res.status(405).json({ error: 'method_not_allowed' }) + return + } + + if (!isSameOriginRequest(req)) { + res.status(403).json({ error: 'forbidden_origin' }) + return + } + + try { + assertTwFidoEnabled() + + const body = parseBody(req.body) + const handoffToken = getString(body.handoffToken, body.handoff_token) + const certChainProof = getString(body.certChainProof, body.cert_chain_proof) + const userSigProof = getString(body.userSigProof, body.user_sig_proof) + if ( + typeof handoffToken !== 'string' || + typeof certChainProof !== 'string' || + typeof userSigProof !== 'string' + ) { + res.status(400).json({ error: 'missing_claim_payload' }) + return + } + + const certChainType = + getString(body.certChainType, body.cert_chain_type) || 'rs4096' + + const data = await requestMattersGraphQL({ + query: CLAIM_PERSONHOOD_BADGE, + variables: { + input: { + certChainProof, + certChainType, + handoffToken, + userSigProof, + }, + }, + }) + + res.status(200).json({ + status: 'claimed', + user: data.claimPersonhoodBadge, + }) + } catch (error) { + const message = error instanceof Error ? error.message : 'unknown_error' + res + .status(message === 'personhood_tw_fido_disabled' ? 404 : 500) + .json({ error: message }) + } +} + +export default handler + +const parseBody = (body: unknown): LinkVerifyBody => { + if (typeof body === 'string') { + return JSON.parse(body) as LinkVerifyBody + } + return (body || {}) as LinkVerifyBody +} + +const getString = (...values: unknown[]) => + values.find((value): value is string => typeof value === 'string') + +const isSameOriginRequest = (req: NextApiRequest) => { + const origin = req.headers.origin + if (!origin) { + return true + } + + const host = req.headers.host + if (!host) { + return false + } + + try { + return new URL(origin).host === host + } catch { + return false + } +} diff --git a/src/pages/circles/create.tsx b/src/pages/circles/create.tsx index 6b7981f0d9..8643f06dfe 100644 --- a/src/pages/circles/create.tsx +++ b/src/pages/circles/create.tsx @@ -1,10 +1,18 @@ -import { Protected } from '~/components' -import CreateCircle from '~/views/CreateCircle' +import { EmptyLayout, Throw404 } from '~/components' +// import { Protected } from '~/components' +// import CreateCircle from '~/views/CreateCircle' -const ProtectedCreateCircle = () => ( - - - +const CirclesCreate = () => ( + + + ) -export default ProtectedCreateCircle +// const ProtectedCreateCircle = () => ( +// +// +// +// ) + +export default CirclesCreate +// export default ProtectedCreateCircle diff --git a/src/pages/me/settings/notifications/circle.tsx b/src/pages/me/settings/notifications/circle.tsx index d8a0c12463..472d7115ec 100644 --- a/src/pages/me/settings/notifications/circle.tsx +++ b/src/pages/me/settings/notifications/circle.tsx @@ -1,10 +1,18 @@ -import { Protected } from '~/components' -import MeSettingsNotificationsCircle from '~/views/Me/Settings/Notifications/CircleSettings' +import { EmptyLayout, Throw404 } from '~/components' +// import { Protected } from '~/components' +// import MeSettingsNotificationsCircle from '~/views/Me/Settings/Notifications/CircleSettings' -const ProtectedMeSettingsNotificationsCircle = () => ( - - - +const MeSettingsNotificationsCirclePage = () => ( + + + ) -export default ProtectedMeSettingsNotificationsCircle +// const ProtectedMeSettingsNotificationsCircle = () => ( +// +// +// +// ) + +export default MeSettingsNotificationsCirclePage +// export default ProtectedMeSettingsNotificationsCircle diff --git a/src/pages/me/settings/personhood/feasibility.tsx b/src/pages/me/settings/personhood/feasibility.tsx new file mode 100644 index 0000000000..a8bb8ec63b --- /dev/null +++ b/src/pages/me/settings/personhood/feasibility.tsx @@ -0,0 +1,10 @@ +import { Protected } from '~/components' +import PersonhoodFeasibility from '~/views/Me/Settings/Personhood/Feasibility' + +const ProtectedPersonhoodFeasibility = () => ( + + + +) + +export default ProtectedPersonhoodFeasibility diff --git a/src/pages/me/settings/personhood/prove.tsx b/src/pages/me/settings/personhood/prove.tsx new file mode 100644 index 0000000000..93147d63d4 --- /dev/null +++ b/src/pages/me/settings/personhood/prove.tsx @@ -0,0 +1,10 @@ +import { Protected } from '~/components' +import PersonhoodProve from '~/views/Me/Settings/Personhood/Prove' + +const ProtectedPersonhoodProve = () => ( + + + +) + +export default ProtectedPersonhoodProve diff --git a/src/pages/moments.tsx b/src/pages/moments.tsx new file mode 100644 index 0000000000..9186f57316 --- /dev/null +++ b/src/pages/moments.tsx @@ -0,0 +1,7 @@ +import HottestMomentsView from '~/views/HottestMoments' + +const HottestMoments = () => { + return +} + +export default HottestMoments diff --git a/src/server/personhood/mattersGraphql.ts b/src/server/personhood/mattersGraphql.ts new file mode 100644 index 0000000000..586ea2f41a --- /dev/null +++ b/src/server/personhood/mattersGraphql.ts @@ -0,0 +1,41 @@ +type GraphQLResponse = { + data?: T + errors?: Array<{ message?: string }> +} + +export const requestMattersGraphQL = async ({ + cookie, + query, + variables, +}: { + cookie?: string + query: string + variables?: Record +}) => { + const apiUrl = process.env.NEXT_PUBLIC_API_URL + if (!apiUrl) { + throw new Error('matters_api_missing_config') + } + + const response = await fetch(apiUrl, { + body: JSON.stringify({ query, variables }), + headers: { + ...(cookie ? { cookie } : null), + 'content-type': 'application/json', + }, + method: 'POST', + }) + const body = (await response.json()) as GraphQLResponse + + if (!response.ok || body.errors?.length) { + throw new Error( + body.errors?.[0]?.message || `matters_api_error_${response.status}` + ) + } + + if (!body.data) { + throw new Error('matters_api_empty_response') + } + + return body.data +} diff --git a/src/server/personhood/twFido.ts b/src/server/personhood/twFido.ts new file mode 100644 index 0000000000..3b1696224e --- /dev/null +++ b/src/server/personhood/twFido.ts @@ -0,0 +1,372 @@ +import crypto from 'node:crypto' + +const DEFAULT_API_BASE_URL = 'https://fidoapi.moi.gov.tw' +const DEFAULT_APP_ID = 'e775f2805fb993e05a208dbff15d1c1' +const DEFAULT_HINT = '待簽署資料' +const DEFAULT_SIGN_TYPE = 'PKCS#1' +const DEFAULT_TIME_LIMIT = '600' + +const PENDING_RESULT_CODES = new Set([ + '20002', + '20003', + 'SP-API-ATH-02-SPTKTID_TXNLOG_NF', + 'SPTKTID_TXNLOG_NF', +]) + +export type TwFidoTicketPayload = { + transaction_id: string + sp_ticket_id: string + expiration_time?: string + sp_name?: string +} + +export type TwFidoSignResult = { + error_code: string + error_message?: string + result?: { + cert?: string + hashed_id_num?: string + idp_checksum?: string + signed_response?: string + } +} + +type TwFidoTicketResponse = { + error_code: string + error_message?: string + result?: { + sp_ticket?: string + } +} + +export type PersonhoodChallenge = { + appId: string + challenge: string + expiresAt?: string +} + +type ChallengeResponse = { + app_id?: string + challenge?: string + expires_at?: string +} + +export type TwFidoConfig = { + aesKeyBase64: string + apiBaseUrl: string + appId: string + hint: string + returnProofInput: boolean + serviceId: string + signType: 'PKCS#1' | 'PKCS#7' | 'RAW' + timeLimit: string + verifierUrl?: string +} + +export const normalizeTwFidoIdNum = (idNum: unknown) => { + if (typeof idNum !== 'string') { + return null + } + + const normalized = idNum.trim().toUpperCase() + if (!/^[A-Z][0-9A-Z]{9}$/.test(normalized)) { + return null + } + + return normalized +} + +export const getTwFidoConfig = (): TwFidoConfig => { + const serviceId = + process.env.PERSONHOOD_FIDO_SP_SERVICE_ID || process.env.FIDO_SP_SERVICE_ID + const aesKeyBase64 = + process.env.PERSONHOOD_FIDO_AES_KEY_BASE64 || + process.env.FIDO_AES_KEY_BASE64 || + process.env.FIDO_AES_KEY + + if (!serviceId || !aesKeyBase64) { + throw new Error('personhood_tw_fido_missing_config') + } + + const appId = process.env.PERSONHOOD_APP_ID || DEFAULT_APP_ID + if (Buffer.byteLength(appId, 'utf8') !== 31) { + throw new Error('personhood_tw_fido_invalid_app_id') + } + + const signType = process.env.PERSONHOOD_FIDO_SIGN_TYPE || DEFAULT_SIGN_TYPE + if (signType !== 'PKCS#1' && signType !== 'PKCS#7' && signType !== 'RAW') { + throw new Error('personhood_tw_fido_invalid_sign_type') + } + + const apiBaseUrl = + process.env.PERSONHOOD_FIDO_API_BASE_URL || DEFAULT_API_BASE_URL + const verifierUrl = + process.env.PERSONHOOD_VERIFIER_URL || + process.env.NEXT_PUBLIC_PERSONHOOD_VERIFIER_URL + + return { + aesKeyBase64, + apiBaseUrl: apiBaseUrl.replace(/\/$/, ''), + appId, + hint: process.env.PERSONHOOD_FIDO_HINT || DEFAULT_HINT, + returnProofInput: + process.env.PERSONHOOD_TW_FIDO_RETURN_PROOF_INPUT === 'true', + serviceId, + signType, + timeLimit: process.env.PERSONHOOD_FIDO_TIME_LIMIT || DEFAULT_TIME_LIMIT, + verifierUrl: verifierUrl ? verifierUrl.replace(/\/$/, '') : undefined, + } +} + +export const assertTwFidoEnabled = () => { + if (process.env.PERSONHOOD_TW_FIDO_ENABLED !== 'true') { + throw new Error('personhood_tw_fido_disabled') + } +} + +export const buildSignData = (appId: string) => + Buffer.from(appId, 'utf8').toString('base64') + +export const createPersonhoodChallenge = async ( + config: TwFidoConfig +): Promise => { + if (!config.verifierUrl) { + return { + appId: config.appId, + challenge: '', + } + } + + const json = await postJson( + `${config.verifierUrl}/challenge`, + {}, + 'challenge' + ) + + if (!json.app_id || !json.challenge) { + throw new Error('personhood_verifier_invalid_challenge') + } + + if (Buffer.byteLength(json.app_id, 'utf8') !== 31) { + throw new Error('personhood_verifier_invalid_app_id') + } + + return { + appId: json.app_id, + challenge: json.challenge, + expiresAt: json.expires_at, + } +} + +export const decodeSpTicket = (spTicket: string): TwFidoTicketPayload => { + const separator = spTicket.lastIndexOf('.') + if (separator < 0) { + throw new Error('personhood_tw_fido_invalid_sp_ticket') + } + + const payload = JSON.parse( + Buffer.from(spTicket.slice(0, separator), 'base64url').toString('utf8') + ) as Partial + + if (!payload.transaction_id || !payload.sp_ticket_id) { + throw new Error('personhood_tw_fido_invalid_sp_ticket_payload') + } + + return { + expiration_time: payload.expiration_time, + sp_name: payload.sp_name, + sp_ticket_id: payload.sp_ticket_id, + transaction_id: payload.transaction_id, + } +} + +export const buildTwFidoDeeplink = ({ + returnUrl, + returnValue, + spTicket, +}: { + returnUrl: string + returnValue?: string + spTicket: string +}) => { + const deeplink = new URL('mobilemoica://moica.moi.gov.tw/a2a/verifySign') + deeplink.searchParams.set('sp_ticket', spTicket) + deeplink.searchParams.set( + 'rtn_url', + Buffer.from(returnUrl, 'utf8').toString('base64url') + ) + deeplink.searchParams.set( + 'rtn_val', + Buffer.from(returnValue || '', 'utf8').toString('base64url') + ) + return deeplink.toString() +} + +export const createSpTicket = async ({ + appId, + config, + idNum, +}: { + appId?: string + config: TwFidoConfig + idNum: string +}) => { + const transactionId = crypto.randomUUID() + const signAppId = appId || config.appId + const signData = buildSignData(signAppId) + const checksumPayload = [ + transactionId, + config.serviceId, + idNum, + 'SIGN', + 'APP2APP', + config.hint, + signData, + ].join('') + + const body = { + hint: config.hint, + id_num: idNum, + op_code: 'SIGN', + op_mode: 'APP2APP', + sign_info: { + hash_algorithm: 'SHA256', + sign_data: signData, + sign_type: config.signType, + tbs_encoding: 'base64', + }, + sp_checksum: computeSpChecksum(checksumPayload, config.aesKeyBase64), + sp_service_id: config.serviceId, + time_limit: config.timeLimit, + transaction_id: transactionId, + } + + const json = await postJson( + `${config.apiBaseUrl}/moise/sp/getSpTicket`, + body, + 'getSpTicket' + ) + if (json.error_code !== '0') { + throw new Error( + `getSpTicket failed: ${json.error_code} ${ + json.error_message || '' + }`.trim() + ) + } + + const spTicket = json.result?.sp_ticket + if (!spTicket || typeof spTicket !== 'string') { + throw new Error('personhood_tw_fido_missing_sp_ticket') + } + + return { + spTicket, + ticketPayload: decodeSpTicket(spTicket), + } +} + +export const getSignResult = async ({ + config, + ticketPayload, +}: { + config: TwFidoConfig + ticketPayload: TwFidoTicketPayload +}) => { + const checksumPayload = [ + ticketPayload.transaction_id, + config.serviceId, + ticketPayload.sp_ticket_id, + ].join('') + + return postJson( + `${config.apiBaseUrl}/moise/sp/getAthOrSignResult`, + { + sp_checksum: computeSpChecksum(checksumPayload, config.aesKeyBase64), + sp_service_id: config.serviceId, + sp_ticket_id: ticketPayload.sp_ticket_id, + transaction_id: ticketPayload.transaction_id, + }, + 'getAthOrSignResult' + ) +} + +export const isPendingSignResult = (errorCode: string) => + PENDING_RESULT_CODES.has(errorCode) + +const normalizeAesKey = (base64: string) => { + const key = Buffer.from(base64, 'base64') + if (key.length !== 32) { + throw new Error('personhood_tw_fido_invalid_aes_key') + } + return key +} + +const computeSpChecksum = (payload: string, aesKeyBase64: string) => { + const key = normalizeAesKey(aesKeyBase64) + const sha256Hex = crypto + .createHash('sha256') + .update(payload, 'utf8') + .digest('hex') + const iv = Buffer.alloc(12) + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv, { + authTagLength: 16, + }) + const ciphertext = Buffer.concat([ + cipher.update(Buffer.from(sha256Hex, 'utf8')), + cipher.final(), + ]) + const tag = cipher.getAuthTag() + + return Buffer.concat([iv, ciphertext, tag]).toString('hex') +} + +const postJson = async ( + url: string, + body: object, + label: string +): Promise => { + let response: Response + try { + response = await fetch(url, { + body: JSON.stringify(body), + headers: { + 'content-type': 'application/json; charset=utf-8', + }, + method: 'POST', + }) + } catch (error) { + throw new Error(`${label}_fetch_failed_${formatFetchCause(error)}`) + } + + const text = await response.text() + + let json: unknown + try { + json = JSON.parse(text) + } catch { + throw new Error(`${label}_invalid_json_response_${response.status}`) + } + + if (!response.ok) { + throw new Error(`${label}_http_${response.status}`) + } + return json as T +} + +const formatFetchCause = (error: unknown) => { + if (!(error instanceof Error)) { + return 'unknown' + } + + const cause = (error as Error & { cause?: unknown }).cause + if (cause && typeof cause === 'object') { + const code = 'code' in cause ? String(cause.code) : undefined + const message = + 'message' in cause && typeof cause.message === 'string' + ? cause.message + : undefined + return [code, message].filter(Boolean).join('_') || error.message + } + + return error.message || 'unknown' +} diff --git a/src/stories/mocks/index.ts b/src/stories/mocks/index.ts index 389ae2b612..33c7deb3cc 100644 --- a/src/stories/mocks/index.ts +++ b/src/stories/mocks/index.ts @@ -6,6 +6,7 @@ export const MOCK_USER = { id: 'VXNlcjox', // User:1 userName: 'matty', displayName: 'Matty', + isMomentFeedApplied: false, avatar: 'https://imagedelivery.net/kDRCweMmqLnTPNlbum-pYA/prod/avatar/19b36f6e-6311-4cd6-b703-c143a4a49113.png/public', liker: { @@ -68,6 +69,10 @@ export const MOCK_USER = { totalCount: 0, }, ownCircles: null, + oss: { + __typename: 'UserOSS' as any, + featureFlags: [], + }, isFollower: false, isFollowee: false, isBlocking: false, diff --git a/src/views/ArticleDetail/Edit/OptionContent/index.tsx b/src/views/ArticleDetail/Edit/OptionContent/index.tsx index 3530f9011f..0bcc080103 100644 --- a/src/views/ArticleDetail/Edit/OptionContent/index.tsx +++ b/src/views/ArticleDetail/Edit/OptionContent/index.tsx @@ -17,6 +17,7 @@ import { DigestRichCirclePublicFragment, DraftDetailViewerQueryQuery, EditorSelectCampaignFragment, + FederationArticleSettingState, } from '~/gql/graphql' import { Article } from '..' @@ -45,7 +46,11 @@ export type OptionContentProps = { replyToDonator: string | null ) => void } & SidebarSensitiveProps & - SidebarISCNProps + SidebarISCNProps & { + federationSetting?: FederationArticleSettingState | null + federationSettingSaving: boolean + editFederationSetting: (state: FederationArticleSettingState) => void + } type OptionItemProps = OptionContentProps & { disabled: boolean } @@ -234,6 +239,22 @@ const EditCircle = ({ ) } +const EditFederationSetting = ({ + article, + federationSetting, + federationSettingSaving, + editFederationSetting, +}: OptionItemProps) => { + return ( + + ) +} + export const OptionContent = ( props: OptionContentProps & { tab: OptionTab @@ -245,6 +266,7 @@ export const OptionContent = ( const isSettings = tab === 'settings' const hasOwnCollections = (props.ownCollections?.length || 0) > 0 const hasOwnCircle = props.ownCircles && props.ownCircles.length >= 1 + const isFediverseBeta = !!props.viewerData?.viewer?.features.fediverseBeta const disabled = false return ( @@ -290,8 +312,13 @@ export const OptionContent = ( + {isFediverseBeta && ( + + )} - {hasOwnCircle && } + {hasOwnCircle && !!props.article.access.circle && ( + + )} )}
diff --git a/src/views/ArticleDetail/Edit/gql.ts b/src/views/ArticleDetail/Edit/gql.ts index 81b6fe7394..abd784f625 100644 --- a/src/views/ArticleDetail/Edit/gql.ts +++ b/src/views/ArticleDetail/Edit/gql.ts @@ -58,6 +58,9 @@ export const GET_EDIT_ARTICLE = gql` canComment indentFirstLine license + federationSetting { + state + } sensitiveByAuthor requestForDonation replyToDonator diff --git a/src/views/ArticleDetail/Edit/index.tsx b/src/views/ArticleDetail/Edit/index.tsx index fb728798d4..88538e919a 100644 --- a/src/views/ArticleDetail/Edit/index.tsx +++ b/src/views/ArticleDetail/Edit/index.tsx @@ -48,6 +48,7 @@ import { DigestTagFragment, DirectImageUploadDoneMutation, DirectImageUploadMutation, + FederationArticleSettingState, PublishState as PublishStateType, QueryEditArticleAssetsQuery, QueryEditArticleQuery, @@ -217,6 +218,11 @@ const BaseEdit = ({ article }: { article: Article }) => { const [indented, setIndented] = useState(article.indentFirstLine) + const [federationSetting, setFederationSetting] = + useState( + article.federationSetting?.state || FederationArticleSettingState.Inherit + ) + const revisionCountLeft = MAX_ARTICLE_REVISION_COUNT - (article?.revisionCount || 0) const isOverRevisionLimit = revisionCountLeft <= 0 @@ -293,6 +299,12 @@ const BaseEdit = ({ article }: { article: Article }) => { iscnPublishSaving: false, } + const federationSettingProps = { + federationSetting, + federationSettingSaving: false, + editFederationSetting: setFederationSetting, + } + const [singleFileUpload] = useMutation(SINGLE_FILE_UPLOAD) const [directImageUpload] = @@ -389,6 +401,7 @@ const BaseEdit = ({ article }: { article: Article }) => { tab={tab} setTab={setTab} article={article} + viewerData={viewerData} ownCircles={ownCircles} ownCollections={ownCollections} campaigns={selectableCampaigns || []} @@ -405,6 +418,7 @@ const BaseEdit = ({ article }: { article: Article }) => { {...supportSettingProps} {...sensitiveProps} {...iscnProps} + {...federationSettingProps} /> ) @@ -501,6 +515,7 @@ const BaseEdit = ({ article }: { article: Article }) => { isOpen={isOpenOptionDrawer} toggleDrawer={toggleOptionDrawer} article={article} + viewerData={viewerData} ownCircles={ownCircles} ownCollections={ownCollections} campaigns={selectableCampaigns || []} @@ -517,6 +532,7 @@ const BaseEdit = ({ article }: { article: Article }) => { {...supportSettingProps} {...sensitiveProps} {...iscnProps} + {...federationSettingProps} /> diff --git a/src/views/ArticleDetail/Support/SupportAuthor/index.tsx b/src/views/ArticleDetail/Support/SupportAuthor/index.tsx index 35d01d4a18..337208b9be 100644 --- a/src/views/ArticleDetail/Support/SupportAuthor/index.tsx +++ b/src/views/ArticleDetail/Support/SupportAuthor/index.tsx @@ -76,7 +76,6 @@ const SupportAuthor = (props: SupportAuthorProps) => { const [windowRef, setWindowRef] = useState(undefined) const { currStep, forward: _forward } = useStep('setAmount') const hasAuthorLikeID = !!recipient.liker.likerId - const supportCurrency = storage.get(SUPPORT_TAB_PREFERENCE_KEY) const { address } = useAccount() const { isUnsupportedNetwork } = useCurationNetwork() @@ -87,9 +86,12 @@ const SupportAuthor = (props: SupportAuthorProps) => { } const [amount, setAmount] = useState(0) - const [currency, setCurrency] = useState( - supportCurrency || CURRENCY.HKD - ) + const [currency, setCurrency] = useState(() => { + const supportCurrency = storage.get(SUPPORT_TAB_PREFERENCE_KEY) + return !supportCurrency || supportCurrency === CURRENCY.LIKE + ? CURRENCY.HKD + : supportCurrency + }) const switchCurrency = async (currency: CURRENCY) => { setAmount(0) diff --git a/src/views/ArticleDetail/Wall/Circle/index.tsx b/src/views/ArticleDetail/Wall/Circle/index.tsx index 024642c399..008ae127f9 100644 --- a/src/views/ArticleDetail/Wall/Circle/index.tsx +++ b/src/views/ArticleDetail/Wall/Circle/index.tsx @@ -1,9 +1,10 @@ -import Link from 'next/link' import { FormattedMessage } from 'react-intl' import IMAGE_WALL_BACKGROUND_MD from '@/public/static/images/circle-wall-background-md.jpg' import IMAGE_WALL_BACKGROUND_SM from '@/public/static/images/circle-wall-background-sm.jpg' -import { toPath } from '~/common/utils' +// FEATURE IS SUNSETTING: circle wall is no longer clickable +// import Link from 'next/link' +// import { toPath } from '~/common/utils' import { CircleWallCirclePrivateFragment, CircleWallCirclePublicFragment, @@ -17,37 +18,27 @@ interface CircleWallProps { Partial } -const CircleWall = ({ circle }: CircleWallProps) => { +const CircleWall = ({}: CircleWallProps) => { const style = { '--circle-wall-bg-sm': `url(${IMAGE_WALL_BACKGROUND_SM.src})`, '--circle-wall-bg-md': `url(${IMAGE_WALL_BACKGROUND_MD.src})`, } as React.CSSProperties - const path = toPath({ - page: 'circleDetail', - circle, - }) + // FEATURE IS SUNSETTING: circle wall is no longer clickable + // const path = toPath({ + // page: 'circleDetail', + // circle, + // }) return (
- -
- -
- -
- +
+ +
) } diff --git a/src/views/ArticleDetail/gql.test.ts b/src/views/ArticleDetail/gql.test.ts new file mode 100644 index 0000000000..af8c26a5db --- /dev/null +++ b/src/views/ArticleDetail/gql.test.ts @@ -0,0 +1,167 @@ +import { parse, print } from 'graphql' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('~/components/UserDigest', () => ({ + UserDigest: { + Rich: { + fragments: { + user: { + public: parse(` + fragment UserDigestRichUserPublic on User { + id + } + `), + private: parse(` + fragment UserDigestRichUserPrivate on User { + id + } + `), + }, + }, + }, + }, +})) + +vi.mock('./AuthorSidebar', () => ({ + AuthorSidebar: { + fragments: { + article: parse(` + fragment AuthorSidebarArticle on Article { + id + } + `), + }, + }, +})) + +vi.mock('./Channel', () => ({ + default: { + fragments: { + article: { + public: parse(` + fragment ChannelArticlePublic on Article { + id + } + `), + private: parse(` + fragment ChannelArticlePrivate on Article { + id + } + `), + }, + }, + }, +})) + +vi.mock('./Header', () => ({ + default: { + fragments: { + article: parse(` + fragment HeaderArticle on Article { + id + } + `), + }, + }, +})) + +vi.mock('./MetaInfo', () => ({ + default: { + fragments: { + article: parse(` + fragment MetaInfoArticle on Article { + id + } + `), + }, + }, +})) + +vi.mock('./StickyTopBanner', () => ({ + default: { + fragments: { + article: parse(` + fragment StickyTopBannerArticle on Article { + id + } + `), + }, + }, +})) + +vi.mock('./Support/SupportWidget/gql', () => ({ + fragments: { + article: { + public: parse(` + fragment SupportWidgetArticlePublic on Article { + id + } + `), + private: parse(` + fragment SupportWidgetArticlePrivate on Article { + id + } + `), + }, + }, +})) + +vi.mock('./TagList', () => ({ + default: { + fragments: { + article: parse(` + fragment TagListArticle on Article { + id + } + `), + }, + }, +})) + +vi.mock('./Toolbar', () => ({ + default: { + fragments: { + article: { + public: parse(` + fragment ToolbarArticlePublic on Article { + id + } + `), + private: parse(` + fragment ToolbarArticlePrivate on Article { + id + } + `), + }, + }, + }, +})) + +vi.mock('./Wall/Circle/gql', () => ({ + fragments: { + circle: { + public: parse(` + fragment CircleWallCirclePublic on Circle { + id + } + `), + private: parse(` + fragment CircleWallCirclePrivate on Circle { + id + } + `), + }, + }, +})) + +describe('ArticleDetail gql', () => { + it('keeps the comment section visible for Community Watch placeholders', async () => { + const { ARTICLE_DETAIL_PUBLIC } = await import('./gql') + const query = print(ARTICLE_DETAIL_PUBLIC) + + expect(query).toContain('comments(input: {filter: {parentComment: null}})') + expect(query).not.toContain( + 'comments(input: {filter: {state: active, parentComment: null}})' + ) + }) +}) diff --git a/src/views/ArticleDetail/gql.ts b/src/views/ArticleDetail/gql.ts index 2a159c0b95..278cd5e3b9 100644 --- a/src/views/ArticleDetail/gql.ts +++ b/src/views/ArticleDetail/gql.ts @@ -45,7 +45,7 @@ const articlePublicFragment = gql` canComment indentFirstLine commentCount - comments(input: { filter: { state: active, parentComment: null } }) { + comments(input: { filter: { parentComment: null } }) { totalCount } license diff --git a/src/views/Callback/index.tsx b/src/views/Callback/index.tsx index c4684cdb68..52d44a43fb 100644 --- a/src/views/Callback/index.tsx +++ b/src/views/Callback/index.tsx @@ -18,6 +18,9 @@ const Callback = () => { {provider === CALLBACK_PROVIDERS.Twitter && ( )} + {/*provider === CALLBACK_PROVIDERS.Threads && ( + + )*/} {provider === CALLBACK_PROVIDERS.EmailVerification && ( )} diff --git a/src/views/Circle/Profile/gql.ts b/src/views/Circle/Profile/gql.ts index b4049d2705..6f57b1ede5 100644 --- a/src/views/Circle/Profile/gql.ts +++ b/src/views/Circle/Profile/gql.ts @@ -2,7 +2,6 @@ import gql from 'graphql-tag' import { CircleAvatar } from '~/components' -import SubscriptionBanner from '../SubscriptionBanner' import AuthorWidget from './AuthorWidget' import DropdownActions from './DropdownActions' import FollowButton from './FollowButton' @@ -31,12 +30,10 @@ const fragments = { ...AvatarCircle ...AuthorWidgetCircle ...DropdownActionsCirclePublic - ...SubscriptionBannerCirclePublic } ${CircleAvatar.fragments.circle} ${AuthorWidget.fragments.circle} ${DropdownActions.fragments.circle.public} - ${SubscriptionBanner.fragments.circle.public} `, private: gql` fragment ProfileCirclePrivate on Circle { @@ -47,11 +44,9 @@ const fragments = { isMember ...FollowButtonCirclePrivate ...DropdownActionsCirclePrivate - ...SubscriptionBannerCirclePrivate } ${FollowButton.fragments.circle.private} ${DropdownActions.fragments.circle.private} - ${SubscriptionBanner.fragments.circle.private} `, }, } diff --git a/src/views/Circle/Profile/index.tsx b/src/views/Circle/Profile/index.tsx index 027f0e7ed0..17b2741a5f 100644 --- a/src/views/Circle/Profile/index.tsx +++ b/src/views/Circle/Profile/index.tsx @@ -1,4 +1,4 @@ -import { useContext, useEffect, useState } from 'react' +import { useContext, useEffect } from 'react' import { FormattedMessage } from 'react-intl' import IMAGE_CIRCLE_COVER from '@/public/static/images/circle-cover.svg?url' @@ -11,7 +11,6 @@ import { Head, Layout, SpinnerBlock, - SubscribeCircleDialog, Throw404, useEventListener, usePublicQuery, @@ -20,7 +19,6 @@ import { } from '~/components' import { CircleProfileCirclePublicQuery } from '~/gql/graphql' -import SubscriptionBanner from '../SubscriptionBanner' import { AddCircleArticle } from './AddCircleArticle' import AuthorWidget from './AuthorWidget' import DropdownActions from './DropdownActions' @@ -46,10 +44,8 @@ const CircleProfile = () => { }) const circle = data?.circle const isOwner = circle?.owner.id === viewer.id - const price = circle?.prices && circle?.prices[0] // private data - const [privateFetched, setPrivateFetched] = useState(false) const loadPrivate = async () => { if (!viewer.isAuthed) { return @@ -60,8 +56,6 @@ const CircleProfile = () => { fetchPolicy: 'network-only', variables: { name }, }) - - setPrivateFetched(true) } // fetch private data @@ -72,8 +66,6 @@ const CircleProfile = () => { if (viewer.isAuthed) { loadPrivate() - } else { - setPrivateFetched(true) } }, [circle?.id]) @@ -163,15 +155,6 @@ const CircleProfile = () => {

{circle.displayName}

- - {price && ( -
- {price.amount} -
- {price.currency} /{' '} - -
- )} {circle.description && ( @@ -187,8 +170,6 @@ const CircleProfile = () => {
)} - {privateFetched && } -
@@ -229,8 +210,6 @@ const CircleProfile = () => { )}
- - ) diff --git a/src/views/Circle/Profile/styles.module.css b/src/views/Circle/Profile/styles.module.css index ad19900f2c..1cd554a733 100644 --- a/src/views/Circle/Profile/styles.module.css +++ b/src/views/Circle/Profile/styles.module.css @@ -40,26 +40,11 @@ font-weight: var(--font-semibold); } } - - & .price { - @mixin border-left-grey; - - flex-shrink: 0; - padding-left: var(--sp24); - margin-left: var(--sp24); - font-size: var(--text16); - font-weight: var(--font-medium); - color: var(--color-matters-gold); - - & .amount { - font-size: var(--text32); - line-height: 2.25rem; - } - } } .description { padding: 0 var(--sp16); + margin-bottom: var(--sp24); @media (--sm-up) { padding: 0; diff --git a/src/views/Circle/Settings/ManageInvitation/Invites/CircleInvitation/index.tsx b/src/views/Circle/Settings/ManageInvitation/Invites/CircleInvitation/index.tsx index 49c5d7c52c..e1e7c57471 100644 --- a/src/views/Circle/Settings/ManageInvitation/Invites/CircleInvitation/index.tsx +++ b/src/views/Circle/Settings/ManageInvitation/Invites/CircleInvitation/index.tsx @@ -14,7 +14,6 @@ import { CircleInvitationFragment } from '~/gql/graphql' import CircleInvitationInvitee from './Invitee' import CircleInvitationPeriod from './Period' -import CircleInvitationResendButton from './Resend' import styles from './styles.module.css' interface CircleInvitationProps { @@ -62,20 +61,12 @@ const CircleInvitationFailedInfo = () => ( * ``` */ export const CircleInvitation = ({ invitation }: CircleInvitationProps) => { - const { circle, freePeriod, invitee, acceptedAt, state } = invitation + const { freePeriod, invitee, acceptedAt, state } = invitation if (!invitee) { return null } - const invitees = [ - { - id: invitee.__typename === 'User' ? invitee.id : null, - email: invitee.__typename === 'Person' ? invitee.email : null, - }, - ] - - const isPending = state === 'pending' const isFailed = state === 'transfer_failed' const isSucceeded = state === 'transfer_succeeded' @@ -90,14 +81,6 @@ export const CircleInvitation = ({ invitation }: CircleInvitationProps) => { state={state} /> - {isPending && ( - - )} - {isFailed && } {isSucceeded && ( @@ -119,9 +102,6 @@ CircleInvitation.fragments = { invitation: gql` fragment CircleInvitation on Invitation { id - circle { - id - } freePeriod invitee { ... on Person { diff --git a/src/views/Circle/Settings/ManageInvitation/index.tsx b/src/views/Circle/Settings/ManageInvitation/index.tsx index 978146348b..a68c3e1fd5 100644 --- a/src/views/Circle/Settings/ManageInvitation/index.tsx +++ b/src/views/Circle/Settings/ManageInvitation/index.tsx @@ -2,7 +2,6 @@ import { FormattedMessage, useIntl } from 'react-intl' import { Head, Layout } from '~/components' -import CircleInvitationAddButton from './AddButton' import InvitesFeed from './Invites' const ManageInvitation = () => { @@ -12,15 +11,9 @@ const ManageInvitation = () => { - - - - - + + + } /> diff --git a/src/views/HottestMoments/Apply/Button/index.tsx b/src/views/HottestMoments/Apply/Button/index.tsx new file mode 100644 index 0000000000..498a7b3374 --- /dev/null +++ b/src/views/HottestMoments/Apply/Button/index.tsx @@ -0,0 +1,48 @@ +import { useContext, useState } from 'react' +import { FormattedMessage } from 'react-intl' + +import { + OPEN_UNIVERSAL_AUTH_DIALOG, + UNIVERSAL_AUTH_TRIGGER, +} from '~/common/enums' +import { Button, TextIcon, ViewerContext } from '~/components' + +import Dialog from '../Dialog' + +const ApplyMomentFeedButton = () => { + const viewer = useContext(ViewerContext) + const [applied, setApplied] = useState(false) + + if (viewer.isMomentFeedApplied || applied) { + return null + } + + const openAuthDialog = () => { + window.dispatchEvent( + new CustomEvent(OPEN_UNIVERSAL_AUTH_DIALOG, { + detail: { trigger: UNIVERSAL_AUTH_TRIGGER.applyMomentFeed }, + }) + ) + } + + return ( + setApplied(true)}> + {({ openDialog }) => ( + + )} + + ) +} + +export default ApplyMomentFeedButton diff --git a/src/views/HottestMoments/Apply/Dialog/index.tsx b/src/views/HottestMoments/Apply/Dialog/index.tsx new file mode 100644 index 0000000000..4c37c8ca5b --- /dev/null +++ b/src/views/HottestMoments/Apply/Dialog/index.tsx @@ -0,0 +1,138 @@ +import gql from 'graphql-tag' +import { FormattedMessage } from 'react-intl' + +import { ERROR_CODES } from '~/common/enums' +import { Dialog, toast, useDialogSwitch, useMutation } from '~/components' +import { ApplyMomentFeedMutation } from '~/gql/graphql' + +const APPLY_MOMENT_FEED = gql` + mutation ApplyMomentFeed { + applyMomentFeed { + id + isMomentFeedApplied + } + } +` + +export interface ApplyMomentFeedDialogProps { + onApplied?: () => void + children: ({ openDialog }: { openDialog: () => void }) => React.ReactNode +} + +const ApplyMomentFeedDialog = ({ + onApplied, + children, +}: ApplyMomentFeedDialogProps) => { + const { show, openDialog, closeDialog } = useDialogSwitch(true) + + const [applyMomentFeed, { loading }] = useMutation( + APPLY_MOMENT_FEED, + undefined, + { + customErrors: { + [ERROR_CODES.BAD_USER_INPUT]: ( + + ), + }, + } + ) + + const onConfirm = async () => { + try { + await applyMomentFeed() + closeDialog() + onApplied?.() + toast.success({ + message: ( + + ), + }) + } catch {} + } + + return ( + <> + {children({ openDialog })} + + + + } + /> + + + +

+ +

+
+
+ + + } + loading={loading} + onClick={onConfirm} + /> + + } + color="greyDarker" + onClick={closeDialog} + /> + + } + smUpBtns={ + <> + + } + color="greyDarker" + onClick={closeDialog} + /> + } + loading={loading} + color="green" + onClick={onConfirm} + /> + + } + /> +
+ + ) +} + +const LazyApplyMomentFeedDialog = (props: ApplyMomentFeedDialogProps) => ( + }> + {({ openDialog }) => <>{props.children({ openDialog })}} + +) + +export default LazyApplyMomentFeedDialog diff --git a/src/views/HottestMoments/Apply/index.tsx b/src/views/HottestMoments/Apply/index.tsx new file mode 100644 index 0000000000..e67e169975 --- /dev/null +++ b/src/views/HottestMoments/Apply/index.tsx @@ -0,0 +1,9 @@ +import Button from './Button' +import Dialog from './Dialog' + +const Apply = { + Button, + Dialog, +} + +export default Apply diff --git a/src/views/HottestMoments/gql.ts b/src/views/HottestMoments/gql.ts new file mode 100644 index 0000000000..0a3e8c40f1 --- /dev/null +++ b/src/views/HottestMoments/gql.ts @@ -0,0 +1,46 @@ +import gql from 'graphql-tag' + +import { MomentDigestFeed } from '~/components/MomentDigest/Feed' + +const momentConnectionFragment = gql` + fragment HottestMomentConnection on MomentConnection { + pageInfo { + startCursor + endCursor + hasNextPage + } + edges { + cursor + node { + ...MomentDigestFeedMomentPublic + } + } + } + ${MomentDigestFeed.fragments.moment.public} +` + +export const HOTTEST_MOMENTS_PUBLIC = gql` + query HottestMomentsPublic($after: String) { + viewer { + id + recommendation { + hottestMoments(input: { first: 20, after: $after }) { + ...HottestMomentConnection + } + } + } + } + ${momentConnectionFragment} +` + +export const HOTTEST_MOMENTS_PRIVATE = gql` + query HottestMomentsPrivate($ids: [ID!]!) { + nodes(input: { ids: $ids }) { + id + ... on Moment { + ...MomentDigestFeedMomentPrivate + } + } + } + ${MomentDigestFeed.fragments.moment.private} +` diff --git a/src/views/HottestMoments/index.tsx b/src/views/HottestMoments/index.tsx new file mode 100644 index 0000000000..2f572ccf41 --- /dev/null +++ b/src/views/HottestMoments/index.tsx @@ -0,0 +1,161 @@ +import { useContext, useEffect, useRef } from 'react' +import { FormattedMessage, useIntl } from 'react-intl' + +import { mergeConnections } from '~/common/utils' +import { + Announcements, + ArticleFeedPlaceholder, + EmptyWork, + Head, + InfiniteScroll, + Layout, + List, + QueryError, + Spacer, + useFetchPolicy, + usePublicQuery, + ViewerContext, +} from '~/components' +import { MomentDigestFeed } from '~/components/MomentDigest/Feed' +import type { HottestMomentsPublicQuery } from '~/gql/graphql' + +import Sidebar from '../Home/Sidebar' +import Apply from './Apply' +import { HOTTEST_MOMENTS_PRIVATE, HOTTEST_MOMENTS_PUBLIC } from './gql' +import styles from './styles.module.css' + +const HottestMoments = () => { + const intl = useIntl() + const viewer = useContext(ViewerContext) + const { fetchPolicy } = useFetchPolicy() + + const { data, loading, error, fetchMore, client } = + usePublicQuery(HOTTEST_MOMENTS_PUBLIC, { + fetchPolicy, + }) + + const connectionPath = 'viewer.recommendation.hottestMoments' + const result = data?.viewer?.recommendation?.hottestMoments + const { edges, pageInfo } = result || {} + const fetchedPrivateRef = useRef(false) + + const loadPrivate = (publicData?: HottestMomentsPublicQuery) => { + if (!viewer.isAuthed || !publicData) { + return + } + + const publicEdges = + publicData.viewer?.recommendation?.hottestMoments?.edges || [] + const publicIds = publicEdges.map(({ node }) => node.id) + + if (publicIds.length === 0) { + return + } + + client.query({ + query: HOTTEST_MOMENTS_PRIVATE, + fetchPolicy: 'network-only', + variables: { ids: publicIds }, + }) + } + + useEffect(() => { + if (loading || !edges || fetchedPrivateRef.current || !viewer.isAuthed) { + return + } + + loadPrivate(data) + fetchedPrivateRef.current = true + }, [!!edges, loading, viewer.id]) + + const loadMore = async () => { + if (loading) { + return + } + + const { data: newData } = await fetchMore({ + variables: { after: pageInfo?.endCursor }, + updateQuery: (previousResult, { fetchMoreResult }) => + mergeConnections({ + oldData: previousResult, + newData: fetchMoreResult, + path: connectionPath, + dedupe: true, + }), + }) + + loadPrivate(newData) + } + + const renderContent = () => { + if (loading) { + return + } + + if (error) { + return + } + + if (!edges || edges.length <= 0 || !pageInfo) { + return ( + + } + /> + ) + } + + return ( + } + eof + > + + {edges.map(({ node, cursor }) => ( + + + + ))} + + + ) + } + + return ( + + + + + + + + + } + > + + +
+
+ +
+
+ +
+
+ + {renderContent()} +
+ ) +} + +export default HottestMoments diff --git a/src/views/HottestMoments/styles.module.css b/src/views/HottestMoments/styles.module.css new file mode 100644 index 0000000000..67a790d9c5 --- /dev/null +++ b/src/views/HottestMoments/styles.module.css @@ -0,0 +1,17 @@ +.headers { + @mixin flex-center-space-between; + + padding-top: var(--sp20); + background-color: var(--color-white); + + & .title { + font-size: var(--text18); + font-weight: var(--font-medium); + line-height: 1.75rem; + color: var(--color-black); + } +} + +.apply { + flex-shrink: 0; +} diff --git a/src/views/Me/DraftDetail/OptionContent/index.tsx b/src/views/Me/DraftDetail/OptionContent/index.tsx index fb408a2fc2..9fb168a037 100644 --- a/src/views/Me/DraftDetail/OptionContent/index.tsx +++ b/src/views/Me/DraftDetail/OptionContent/index.tsx @@ -192,29 +192,6 @@ const EditDraftSupportSetting = ({ draft }: OptionItemProps) => { ) } -const EditDraftCircle = ({ draft, ownCircles }: OptionItemProps) => { - const { edit, saving } = useEditDraftAccess(ownCircles && ownCircles[0]) - - const hasOwnCircle = ownCircles && ownCircles.length >= 1 - const circle = draft.access?.circle - - if (!hasOwnCircle) { - return null - } - - const checked = ownCircles?.[0].id === circle?.id - - return ( - - ) -} - const EditDraftSensitive = ({ draft }: OptionItemProps) => { const { edit, saving } = useEditDraftSensitiveByAuthor() const sensitive = draft.sensitiveByAuthor @@ -241,6 +218,7 @@ export const OptionContent = ( const isPublished = props.draft.publishState === 'published' const disabled = isPending || isPublished const hasOwnCollections = (props.ownCollections?.length || 0) > 0 + const isFediverseBeta = !!props.draftViewer?.viewer?.features.fediverseBeta return (
@@ -290,8 +268,8 @@ export const OptionContent = ( + {isFediverseBeta && } - )}
diff --git a/src/views/Me/DraftDetail/gql.ts b/src/views/Me/DraftDetail/gql.ts index 4c8ccbd481..68928bcce4 100644 --- a/src/views/Me/DraftDetail/gql.ts +++ b/src/views/Me/DraftDetail/gql.ts @@ -87,6 +87,9 @@ export const DRAFT_DETAIL_VIEWER = gql` } displayName avatar + features { + fediverseBeta + } collections(input: { first: 20, after: $collectionsAfter }) { pageInfo { hasNextPage diff --git a/src/views/Me/Settings/Account/Socials/index.tsx b/src/views/Me/Settings/Account/Socials/index.tsx index 52d5017e72..10ecc2bc52 100644 --- a/src/views/Me/Settings/Account/Socials/index.tsx +++ b/src/views/Me/Settings/Account/Socials/index.tsx @@ -3,6 +3,8 @@ import { useContext, useEffect, useState } from 'react' import { FormattedMessage } from 'react-intl' import IconGoogle2 from '@/public/static/icons/24px/google2.svg' +// temporarily hidden: Threads bind/unbind +// import IconThreads from '@/public/static/icons/24px/threads.svg' import IconTimes from '@/public/static/icons/24px/times.svg' import IconX2 from '@/public/static/icons/24px/x2.svg' import { @@ -12,10 +14,12 @@ import { OAUTH_STORAGE_BIND_STATE_UNAVAILABLE, } from '~/common/enums' import { + // analytics, googleOauthUrl, isSafari, sleep, storage, + // threadsOauthUrl, twitterOauthUrl, } from '~/common/utils' import { @@ -43,11 +47,15 @@ const Socials = () => { const twitterId = viewer.info.socialAccounts.find( (s) => s.type === SocialAccountType.Twitter )?.userName + // const threadsId = viewer.info.socialAccounts.find( + // (s) => s.type === SocialAccountType.Threads + // )?.userName const { router } = useRoute() const [loadingState, setLoadingState] = useState('') const isGoogleLoading = loadingState === 'Google' const isTwitterLoading = loadingState === 'Twitter' + // const isThreadsLoading = loadingState === 'Threads' const oauthType = 'bind' @@ -76,6 +84,13 @@ const Socials = () => { } } + // const gotoThreads = async () => { + // analytics.trackEvent('click_button', { type: 'bind_threads' }) + // setLoadingState('Threads') + // const url = await threadsOauthUrl(oauthType) + // router.push(url) + // } + useEffect(() => { const bindResult = storage.remove<{ type: SocialAccountType @@ -221,6 +236,50 @@ const Socials = () => { ) }} + + {/* Threads */} + {/* + + {({ openDialog }) => { + return ( + } + spacing={12} + > + Threads +
+ } + rightText={threadsId ? `@${threadsId}` : undefined} + rightIcon={ + threadsId ? ( + + ) : undefined + } + onClick={threadsId ? () => openDialog() : undefined} + right={ + threadsId ? undefined : ( + <> + {!isThreadsLoading && ( + + + + )} + {isThreadsLoading && ( + + )} + + ) + } + /> + ) + }} + + */} ) } diff --git a/src/views/Me/Settings/Misc/FederationSetting.test.tsx b/src/views/Me/Settings/Misc/FederationSetting.test.tsx new file mode 100644 index 0000000000..dfda36f9b0 --- /dev/null +++ b/src/views/Me/Settings/Misc/FederationSetting.test.tsx @@ -0,0 +1,65 @@ +import { render, screen } from '@testing-library/react' +import { IntlProvider } from 'react-intl' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import FederationSetting from './FederationSetting' + +const useQuery = vi.fn() +const messages = { + YC2b3b: 'Fediverse 聯邦發佈', +} + +vi.mock('@apollo/client', () => ({ + useQuery: () => useQuery(), +})) + +vi.mock('~/components', () => ({ + Switch: () => , + TableView: { + Cell: ({ title }: { title: React.ReactNode }) =>
{title}
, + }, + toast: { + error: vi.fn(), + success: vi.fn(), + }, + useMutation: () => [vi.fn(), { loading: false }], +})) + +describe('', () => { + beforeEach(() => { + useQuery.mockReset() + }) + + it('does not render before viewer feature eligibility is confirmed', () => { + useQuery.mockReturnValue({ data: undefined, loading: true }) + + render( + + + + ) + + expect(screen.queryByText('Fediverse 聯邦發佈')).not.toBeInTheDocument() + }) + + it('renders for fediverse beta users', () => { + useQuery.mockReturnValue({ + data: { + viewer: { + id: '1', + features: { fediverseBeta: true }, + federationSetting: { state: 'disabled' }, + }, + }, + loading: false, + }) + + render( + + + + ) + + expect(screen.getByText('Fediverse 聯邦發佈')).toBeInTheDocument() + }) +}) diff --git a/src/views/Me/Settings/Misc/FederationSetting.tsx b/src/views/Me/Settings/Misc/FederationSetting.tsx new file mode 100644 index 0000000000..6fe2c5a756 --- /dev/null +++ b/src/views/Me/Settings/Misc/FederationSetting.tsx @@ -0,0 +1,135 @@ +import { useQuery } from '@apollo/client' +import gql from 'graphql-tag' +import { useEffect, useState } from 'react' +import { FormattedMessage, useIntl } from 'react-intl' + +import { Switch, TableView, toast, useMutation } from '~/components' +import { + FederationAuthorSettingState, + SetViewerFederationSettingMutation, + SetViewerFederationSettingMutationVariables, + ViewerFederationSettingQuery, +} from '~/gql/graphql' + +const VIEWER_FEDERATION_SETTING = gql` + query ViewerFederationSetting { + viewer { + id + features { + fediverseBeta + } + federationSetting { + state + } + } + } +` + +const SET_VIEWER_FEDERATION_SETTING = gql` + mutation SetViewerFederationSetting( + $input: SetViewerFederationSettingInput! + ) { + setViewerFederationSetting(input: $input) { + userId + state + } + } +` + +const FederationSetting = () => { + const intl = useIntl() + const [setting, setSetting] = useState(FederationAuthorSettingState.Disabled) + const { data, loading } = useQuery( + VIEWER_FEDERATION_SETTING, + { fetchPolicy: 'cache-and-network' } + ) + const [setViewerFederationSetting, { loading: saving }] = useMutation< + SetViewerFederationSettingMutation, + SetViewerFederationSettingMutationVariables + >(SET_VIEWER_FEDERATION_SETTING) + + const viewer = data?.viewer + const isFediverseBeta = !!viewer?.features.fediverseBeta + + useEffect(() => { + if (viewer?.federationSetting?.state) { + setSetting(viewer.federationSetting.state) + } + }, [viewer?.federationSetting?.state]) + + if (!isFediverseBeta) { + return null + } + + const enabled = setting === FederationAuthorSettingState.Enabled + + const updateSetting = async () => { + if (!viewer?.id) { + return + } + + const state = enabled + ? FederationAuthorSettingState.Disabled + : FederationAuthorSettingState.Enabled + + try { + await setViewerFederationSetting({ + variables: { input: { state } }, + optimisticResponse: { + setViewerFederationSetting: { + __typename: 'UserFederationSetting', + userId: viewer.id, + state, + }, + }, + }) + setSetting(state) + toast.success({ + message: intl.formatMessage({ + defaultMessage: '已更新 Fediverse 設定', + id: 'mFn/Vv', + }), + }) + } catch { + toast.error({ + message: intl.formatMessage({ + defaultMessage: '操作失敗,請稍後再試', + id: 'vXgChH', + }), + }) + } + } + + return ( + + } + subtitle={ + + } + right={ + + } + checked={enabled} + loading={loading || saving} + onChange={updateSetting} + /> + } + /> + ) +} + +export default FederationSetting diff --git a/src/views/Me/Settings/Misc/index.tsx b/src/views/Me/Settings/Misc/index.tsx index 5009ffbe4e..316ae361fb 100644 --- a/src/views/Me/Settings/Misc/index.tsx +++ b/src/views/Me/Settings/Misc/index.tsx @@ -6,6 +6,7 @@ import SettingsTabs from '../SettingsTabs' import styles from '../styles.module.css' import BlockedUsers from './BlockedUsers' import Currency from './Currency' +import FederationSetting from './FederationSetting' import LikerID from './LikerID' const SettingsMisc = () => { @@ -33,6 +34,7 @@ const SettingsMisc = () => {
+ diff --git a/src/views/Me/Settings/Notifications/GeneralSettings/index.tsx b/src/views/Me/Settings/Notifications/GeneralSettings/index.tsx index e2a6f7c386..e3c3370f1c 100644 --- a/src/views/Me/Settings/Notifications/GeneralSettings/index.tsx +++ b/src/views/Me/Settings/Notifications/GeneralSettings/index.tsx @@ -2,7 +2,8 @@ import { useQuery } from '@apollo/client' import gql from 'graphql-tag' import { FormattedMessage } from 'react-intl' -import { PATHS } from '~/common/enums' +// FEATURE IS SUNSETTING: circle notifications entry is hidden +// import { PATHS } from '~/common/enums' import { SpinnerBlock, Switch, TableView, useMutation } from '~/components' import { UpdateViewerNotificationsGeneralMutation, @@ -248,7 +249,8 @@ const NotificationsGeneralSettings = () => { {/* Entry: circle notifications */} - + */} ) } diff --git a/src/views/Me/Settings/Personhood/Feasibility/index.tsx b/src/views/Me/Settings/Personhood/Feasibility/index.tsx new file mode 100644 index 0000000000..932a4bd4ae --- /dev/null +++ b/src/views/Me/Settings/Personhood/Feasibility/index.tsx @@ -0,0 +1,1011 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { FormattedMessage, useIntl } from 'react-intl' + +import { Head, Layout, Spacer } from '~/components' + +import SettingsTabs from '../../SettingsTabs' +import settingsStyles from '../../styles.module.css' +import { + BROWSER_PROOF_ROUTE, + buildBrowserProofHandoff, + buildIsolatedProverUrl, + type PersonhoodProofInput, + saveBrowserProofHandoff, +} from '../handoff' +import styles from './styles.module.css' + +type StorageEstimateReport = { + quota?: number + usage?: number + available?: number +} + +type WasmProbeReport = { + startedAt: string + endedAt?: string + completed?: boolean + totalBytes?: number + error?: string + allocations: Array<{ + round: number + bytes: number + totalBytes: number + }> +} + +type FeasibilityReport = { + startedAt: string + userAgent: string + platform: string + language: string + standalone: boolean + secureContext: boolean + crossOriginIsolated: boolean + checks: { + webAssembly?: boolean + serviceWorker?: boolean + indexedDB?: boolean + storageManager?: boolean + hardwareConcurrency?: number | null + deviceMemory?: number | null + storageEstimate?: StorageEstimateReport + wasmMemoryProbe?: WasmProbeReport + } +} + +type SpTicketResponse = { + appId: string + apiBaseUrl: string + challenge: string + challengeExpiresAt?: string + deeplink: string + expiresAt?: string + signType: string + spTicket: string + spTicketId: string + status: 'ticket_created' + transactionId: string +} + +type SignResultResponse = + | { + cert?: string + certSize: number + proofInput?: PersonhoodProofInput + signedResponse?: string + signedResponseSize: number + status: 'signed' + } + | { + errorCode: string + message?: string + status: 'pending' + } + +type ProofInput = NonNullable< + Extract['proofInput'] +> + +type TwFidoState = { + error?: string + handoff?: { + challenge: string + error?: string + expiresAt?: string + status: 'creating' | 'ready' + token?: string + } + idNum: string + pollCount: number + proofInputReady: boolean + result?: SignResultResponse + status: 'idle' | 'creating' | 'ticket_created' | 'polling' | 'signed' + ticket?: SpTicketResponse +} + +const POLL_INTERVAL_MS = 4000 +const TW_FIDO_SESSION_STORAGE_KEY = 'matters.personhood.twFidoSession.v1' +const TW_FIDO_SESSION_MAX_AGE_MS = 15 * 60 * 1000 + +type StoredTwFidoSession = { + savedAt: string + ticket: SpTicketResponse +} + +const bytesToMiB = (bytes?: number) => { + if (!bytes) { + return '0 MiB' + } + return `${Math.round(bytes / 1024 / 1024)} MiB` +} + +const parseTime = (value?: string) => { + if (!value) { + return undefined + } + + const numeric = Number(value) + if (Number.isFinite(numeric)) { + return numeric > 1_000_000_000_000 ? numeric : numeric * 1000 + } + + const parsed = Date.parse(value) + return Number.isNaN(parsed) ? undefined : parsed +} + +const isExpiredTicket = (ticket: SpTicketResponse) => { + const expiresAt = parseTime(ticket.expiresAt) + return expiresAt ? Date.now() > expiresAt : false +} + +const isExpiredStoredSession = (stored: StoredTwFidoSession) => { + const savedAt = parseTime(stored.savedAt) + return ( + isExpiredTicket(stored.ticket) || + (savedAt ? Date.now() - savedAt > TW_FIDO_SESSION_MAX_AGE_MS : false) + ) +} + +const getTwFidoSessionStorages = () => { + if (typeof window === 'undefined') { + return [] + } + + return [window.localStorage, window.sessionStorage] +} + +const loadStoredTwFidoSession = () => { + if (typeof window === 'undefined') { + return undefined + } + + for (const storage of getTwFidoSessionStorages()) { + try { + const raw = storage.getItem(TW_FIDO_SESSION_STORAGE_KEY) + if (!raw) { + continue + } + + const stored = JSON.parse(raw) as StoredTwFidoSession + if (!stored.ticket || isExpiredStoredSession(stored)) { + storage.removeItem(TW_FIDO_SESSION_STORAGE_KEY) + continue + } + + return stored + } catch { + storage.removeItem(TW_FIDO_SESSION_STORAGE_KEY) + } + } + + return undefined +} + +const saveTwFidoSession = (ticket: SpTicketResponse) => { + if (typeof window === 'undefined') { + return + } + + const value = JSON.stringify({ + savedAt: new Date().toISOString(), + ticket, + } satisfies StoredTwFidoSession) + + for (const storage of getTwFidoSessionStorages()) { + try { + storage.setItem(TW_FIDO_SESSION_STORAGE_KEY, value) + } catch { + // Keep the flow usable if one storage backend is unavailable. + } + } +} + +const clearTwFidoSession = () => { + if (typeof window === 'undefined') { + return + } + + for (const storage of getTwFidoSessionStorages()) { + try { + storage.removeItem(TW_FIDO_SESSION_STORAGE_KEY) + } catch { + // Ignore storage cleanup failures. + } + } +} + +const createInitialReport = (): FeasibilityReport => { + if (typeof window === 'undefined') { + return { + startedAt: '', + userAgent: '', + platform: '', + language: '', + standalone: false, + secureContext: false, + crossOriginIsolated: false, + checks: {}, + } + } + + const nav = window.navigator as Navigator & { standalone?: boolean } + + return { + startedAt: new Date().toISOString(), + userAgent: nav.userAgent, + platform: nav.platform, + language: nav.language, + standalone: + window.matchMedia('(display-mode: standalone)').matches || + nav.standalone === true, + secureContext: window.isSecureContext, + crossOriginIsolated: window.crossOriginIsolated, + checks: {}, + } +} + +const PersonhoodFeasibility = () => { + const intl = useIntl() + const [report, setReport] = useState(createInitialReport) + const [running, setRunning] = useState<'basic' | 'wasm' | null>(null) + const [copied, setCopied] = useState(false) + const [browserProofError, setBrowserProofError] = useState() + const [desktopLinkCopied, setDesktopLinkCopied] = useState(false) + const [twFidoLinkCopied, setTwFidoLinkCopied] = useState(false) + const [twFido, setTwFido] = useState({ + idNum: '', + pollCount: 0, + proofInputReady: false, + status: 'idle', + }) + const pollingRef = useRef(null) + + const reportText = useMemo(() => JSON.stringify(report, null, 2), [report]) + const canCreateTicket = + twFido.status !== 'creating' && + twFido.status !== 'polling' && + /^[A-Z][0-9A-Z]{9}$/.test(twFido.idNum.trim().toUpperCase()) + + useEffect(() => { + const stored = loadStoredTwFidoSession() + if (!stored) { + return + } + + setTwFido((current) => { + if (current.ticket || current.status !== 'idle') { + return current + } + + return { + ...current, + pollCount: 0, + proofInputReady: false, + status: 'ticket_created', + ticket: stored.ticket, + } + }) + }, []) + + const runBasicChecks = useCallback(async () => { + if (typeof window === 'undefined') { + return + } + + setRunning('basic') + const nav = window.navigator as Navigator & { + deviceMemory?: number + } + + const nextReport = createInitialReport() + nextReport.checks = { + webAssembly: typeof WebAssembly !== 'undefined', + serviceWorker: 'serviceWorker' in nav, + indexedDB: 'indexedDB' in window, + storageManager: !!nav.storage, + hardwareConcurrency: nav.hardwareConcurrency || null, + deviceMemory: nav.deviceMemory || null, + } + + if (nav.storage?.estimate) { + const estimate = await nav.storage.estimate() + nextReport.checks.storageEstimate = { + quota: estimate.quota, + usage: estimate.usage, + available: + estimate.quota && estimate.usage + ? estimate.quota - estimate.usage + : undefined, + } + } + + setReport(nextReport) + setRunning(null) + }, []) + + const runWasmProbe = useCallback(async () => { + if (typeof WebAssembly === 'undefined') { + return + } + + setRunning('wasm') + const allocations: WasmProbeReport['allocations'] = [] + const memories: WebAssembly.Memory[] = [] + let totalBytes = 0 + const pages = 256 + const maxRounds = 64 + + const probe: WasmProbeReport = { + startedAt: new Date().toISOString(), + allocations, + } + + setReport((current) => ({ + ...current, + checks: { ...current.checks, wasmMemoryProbe: probe }, + })) + + try { + for (let i = 0; i < maxRounds; i += 1) { + const memory = new WebAssembly.Memory({ initial: pages }) + const bytes = memory.buffer.byteLength + memories.push(memory) + totalBytes += bytes + allocations.push({ round: i + 1, bytes, totalBytes }) + + setReport((current) => ({ + ...current, + checks: { + ...current.checks, + wasmMemoryProbe: { + ...probe, + totalBytes, + allocations: [...allocations], + }, + }, + })) + + await new Promise((resolve) => setTimeout(resolve, 50)) + } + + probe.completed = true + } catch (error) { + probe.completed = false + probe.error = + error instanceof Error ? error.message : 'Unknown WASM memory error' + } finally { + probe.endedAt = new Date().toISOString() + probe.totalBytes = totalBytes + memories.length = 0 + setReport((current) => ({ + ...current, + checks: { + ...current.checks, + wasmMemoryProbe: { + ...probe, + allocations: [...allocations], + }, + }, + })) + setRunning(null) + } + }, []) + + const copyReport = useCallback(async () => { + if (navigator.clipboard) { + await navigator.clipboard.writeText(reportText) + } + setCopied(true) + window.setTimeout(() => setCopied(false), 1600) + }, [reportText]) + + const openTwFido = useCallback(() => { + setBrowserProofError(undefined) + + if (!twFido.ticket?.deeplink) { + setBrowserProofError('TW FidO link is not ready.') + return + } + + window.location.href = twFido.ticket.deeplink + }, [twFido.ticket?.deeplink]) + + const copyTwFidoLink = useCallback(async () => { + setBrowserProofError(undefined) + + if (!twFido.ticket?.deeplink) { + setBrowserProofError('TW FidO link is not ready.') + return + } + + try { + await navigator.clipboard?.writeText(twFido.ticket.deeplink) + setTwFidoLinkCopied(true) + window.setTimeout(() => setTwFidoLinkCopied(false), 1600) + } catch (error) { + setBrowserProofError( + error instanceof Error ? error.message : 'Could not copy TW FidO link.' + ) + } + }, [twFido.ticket?.deeplink]) + + const createHandoff = useCallback(async (proofInput: ProofInput) => { + setTwFido((current) => ({ + ...current, + handoff: { + challenge: proofInput.challenge, + status: 'creating', + }, + })) + + try { + const response = await fetch('/api/personhood/zkid/handoff', { + body: JSON.stringify({ + challenge: proofInput.challenge, + challengeExpiresAt: proofInput.challengeExpiresAt, + }), + headers: { + 'content-type': 'application/json', + }, + method: 'POST', + }) + const body = await response.json() + + if (!response.ok) { + throw new Error(body.error || `HTTP ${response.status}`) + } + + setTwFido((current) => ({ + ...current, + handoff: { + challenge: proofInput.challenge, + expiresAt: body.expiresAt, + status: 'ready', + token: body.token, + }, + })) + } catch (error) { + setTwFido((current) => ({ + ...current, + handoff: { + challenge: proofInput.challenge, + error: error instanceof Error ? error.message : 'Unknown error', + status: 'ready', + }, + })) + } + }, []) + + const pollSignResult = useCallback( + async (spTicket?: string) => { + const ticket = spTicket || twFido.ticket?.spTicket + if (!ticket) { + return + } + + setTwFido((current) => ({ + ...current, + error: undefined, + pollCount: current.pollCount + 1, + status: 'polling', + })) + + try { + const response = await fetch('/api/personhood/tw-fido/result', { + body: JSON.stringify({ + appId: twFido.ticket?.appId, + challenge: twFido.ticket?.challenge, + challengeExpiresAt: twFido.ticket?.challengeExpiresAt, + spTicket: ticket, + }), + headers: { + 'content-type': 'application/json', + }, + method: 'POST', + }) + const body = await response.json() + + if (!response.ok) { + throw new Error(body.error || `HTTP ${response.status}`) + } + + if (body.status === 'signed') { + setTwFido((current) => ({ + ...current, + handoff: undefined, + proofInputReady: !!body.proofInput, + result: body, + status: 'signed', + })) + return + } + + setTwFido((current) => ({ + ...current, + result: body, + status: 'ticket_created', + })) + } catch (error) { + setTwFido((current) => ({ + ...current, + error: error instanceof Error ? error.message : 'Unknown error', + status: current.ticket ? 'ticket_created' : 'idle', + })) + } + }, + [ + twFido.ticket?.appId, + twFido.ticket?.challenge, + twFido.ticket?.challengeExpiresAt, + twFido.ticket?.spTicket, + ] + ) + + const createTicket = useCallback(async () => { + if (!canCreateTicket || typeof window === 'undefined') { + return + } + + const idNum = twFido.idNum.trim().toUpperCase() + clearTwFidoSession() + setTwFido((current) => ({ + ...current, + error: undefined, + handoff: undefined, + pollCount: 0, + proofInputReady: false, + result: undefined, + status: 'creating', + ticket: undefined, + })) + + try { + const response = await fetch('/api/personhood/tw-fido/sp-ticket', { + body: JSON.stringify({ + idNum, + returnUrl: window.location.href, + }), + headers: { + 'content-type': 'application/json', + }, + method: 'POST', + }) + const body = await response.json() + + if (!response.ok) { + throw new Error(body.error || `HTTP ${response.status}`) + } + + saveTwFidoSession(body) + setTwFido((current) => ({ + ...current, + idNum, + status: 'ticket_created', + ticket: body, + })) + } catch (error) { + setTwFido((current) => ({ + ...current, + error: error instanceof Error ? error.message : 'Unknown error', + status: 'idle', + })) + } + }, [canCreateTicket, twFido.idNum]) + + useEffect(() => { + if (twFido.status !== 'ticket_created' || !twFido.ticket) { + return + } + + pollingRef.current = window.setTimeout(() => { + pollSignResult(twFido.ticket?.spTicket) + }, POLL_INTERVAL_MS) + + return () => { + if (pollingRef.current) { + window.clearTimeout(pollingRef.current) + } + } + }, [pollSignResult, twFido.status, twFido.ticket]) + + useEffect(() => { + if (twFido.result?.status !== 'signed' || !twFido.result.proofInput) { + return + } + + if (twFido.handoff?.challenge === twFido.result.proofInput.challenge) { + return + } + + createHandoff(twFido.result.proofInput) + }, [ + createHandoff, + twFido.handoff?.challenge, + twFido.result, + twFido.result?.status, + ]) + + const storage = report.checks.storageEstimate + const wasmProbe = report.checks.wasmMemoryProbe + const signedResult = + twFido.result?.status === 'signed' ? twFido.result : undefined + const browserProofHandoff = useMemo(() => { + if ( + typeof window === 'undefined' || + !signedResult?.proofInput || + !twFido.handoff?.token + ) { + return undefined + } + + return buildBrowserProofHandoff({ + handoffExpiresAt: twFido.handoff.expiresAt, + handoffToken: twFido.handoff.token, + origin: window.location.origin, + proofInput: signedResult.proofInput, + returnUrl: window.location.href, + }) + }, [ + signedResult?.proofInput, + twFido.handoff?.expiresAt, + twFido.handoff?.token, + ]) + const canStartBrowserProof = !!browserProofHandoff + const browserHandoffBytes = browserProofHandoff + ? new TextEncoder().encode(JSON.stringify(browserProofHandoff)).byteLength + : 0 + const desktopProverUrl = useMemo(() => { + if (!browserProofHandoff || typeof window === 'undefined') { + return undefined + } + + return buildIsolatedProverUrl({ + handoff: browserProofHandoff, + origin: window.location.origin, + }) + }, [browserProofHandoff]) + const desktopProverLinkBytes = desktopProverUrl + ? new TextEncoder().encode(desktopProverUrl).byteLength + : 0 + + const startBrowserProof = useCallback(() => { + setBrowserProofError(undefined) + + if (!browserProofHandoff || typeof window === 'undefined') { + setBrowserProofError('Browser proof handoff is not ready.') + return + } + + try { + saveBrowserProofHandoff(browserProofHandoff) + window.location.assign(BROWSER_PROOF_ROUTE) + } catch (error) { + setBrowserProofError( + error instanceof Error ? error.message : 'Could not save handoff.' + ) + } + }, [browserProofHandoff]) + + const copyDesktopProverLink = useCallback(async () => { + setBrowserProofError(undefined) + + if (!desktopProverUrl) { + setBrowserProofError('Desktop proof link is not ready.') + return + } + + try { + await navigator.clipboard?.writeText(desktopProverUrl) + setDesktopLinkCopied(true) + window.setTimeout(() => setDesktopLinkCopied(false), 1600) + } catch (error) { + setBrowserProofError( + error instanceof Error ? error.message : 'Could not copy desktop link.' + ) + } + }, [desktopProverUrl]) + + return ( + + + + + } + /> + + + + + +
+
+
+

+ +

+

+ +

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

+ +

+ +
+
+
Status
+
{twFido.status}
+
+
+
APP_ID
+
{twFido.ticket?.appId || 'not created'}
+
+
+
Challenge
+
{twFido.ticket?.challenge || 'not created'}
+
+
+
Challenge expires
+
{twFido.ticket?.challengeExpiresAt || 'not created'}
+
+
+
Ticket ID
+
{twFido.ticket?.spTicketId || 'not created'}
+
+
+
Sign type
+
{twFido.ticket?.signType || 'not created'}
+
+
+
Poll count
+
{twFido.pollCount}
+
+
+
Proof input
+
{twFido.proofInputReady ? 'ready' : 'server held'}
+
+
+
Browser handoff
+
+ {browserProofHandoff + ? 'ready' + : twFido.handoff?.status || 'not ready'} +
+
+
+
Handoff expires
+
{twFido.handoff?.expiresAt || 'not created'}
+
+
+
Handoff bytes
+
{browserHandoffBytes}
+
+
+
Mac link bytes
+
{desktopProverLinkBytes}
+
+
+
Certificate bytes
+
{signedResult?.certSize || 0}
+
+
+
Signature bytes
+
{signedResult?.signedResponseSize || 0}
+
+
+ + {(twFido.error || twFido.handoff?.error || browserProofError) && ( +

+ {twFido.error || twFido.handoff?.error || browserProofError} +

+ )} +
+ +
+
+

+ +

+

+ +

+
+ +
+ + + +
+ +
+
+
Standalone
+
{report.standalone ? 'yes' : 'no'}
+
+
+
Secure context
+
{report.secureContext ? 'yes' : 'no'}
+
+
+
Cross origin isolated
+
{report.crossOriginIsolated ? 'yes' : 'no'}
+
+
+
Storage quota
+
{bytesToMiB(storage?.quota)}
+
+
+
WASM allocated
+
{bytesToMiB(wasmProbe?.totalBytes)}
+
+
+
WASM result
+
+ {wasmProbe + ? wasmProbe.completed === false + ? 'failed' + : wasmProbe.completed + ? 'completed' + : 'running' + : 'not run'} +
+
+
+ +
{reportText}
+
+
+ + +
+ ) +} + +export default PersonhoodFeasibility diff --git a/src/views/Me/Settings/Personhood/Feasibility/styles.module.css b/src/views/Me/Settings/Personhood/Feasibility/styles.module.css new file mode 100644 index 0000000000..c6421c788e --- /dev/null +++ b/src/views/Me/Settings/Personhood/Feasibility/styles.module.css @@ -0,0 +1,142 @@ +.panel { + padding: var(--sp16); + margin-bottom: var(--sp16); + border: 1px solid var(--color-grey-lighter); + border-radius: 8px; +} + +.header { + & h2 { + margin: 0 0 var(--sp8); + font-size: var(--text18); + font-weight: var(--font-semibold); + } + + & p { + margin: 0; + font-size: var(--text14); + color: var(--color-grey-darker); + } +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: var(--sp8); + margin: var(--sp16) 0; +} + +.button, +.buttonSecondary, +.linkButton { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 120px; + min-height: 40px; + padding: 0 var(--sp16); + font-size: var(--text14); + font-weight: var(--font-medium); + text-align: center; + border-radius: 6px; +} + +.button, +.linkButton { + color: var(--color-white); + background: var(--color-matters-green); +} + +.buttonSecondary { + color: var(--color-matters-green); + background: transparent; + border: 1px solid var(--color-matters-green); +} + +.button:disabled, +.buttonSecondary:disabled, +.linkButton[aria-disabled='true'] { + pointer-events: none; + cursor: not-allowed; + opacity: 0.5; +} + +.formRow { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: var(--sp8); + align-items: end; + margin: var(--sp16) 0 0; +} + +.field { + display: grid; + gap: var(--sp4); + + & span { + font-size: var(--text12); + color: var(--color-grey-darker); + } + + & input { + min-height: 40px; + padding: 0 var(--sp12); + font-family: monospace; + font-size: var(--text14); + text-transform: uppercase; + border: 1px solid var(--color-grey-lighter); + border-radius: 6px; + } +} + +.metrics { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: var(--sp8); + margin: 0 0 var(--sp16); + + & div { + padding: var(--sp12); + background: var(--color-grey-lighter); + border-radius: 6px; + } + + & dt { + font-size: var(--text12); + color: var(--color-grey-darker); + } + + & dd { + margin: var(--sp4) 0 0; + font-family: monospace; + font-size: var(--text14); + overflow-wrap: anywhere; + } +} + +.report { + max-height: 420px; + padding: var(--sp16); + margin: 0; + overflow: auto; + font-size: var(--text12); + line-height: 1.5; + color: var(--color-white); + white-space: pre-wrap; + background: var(--color-black); + border-radius: 6px; +} + +.error { + margin: var(--sp8) 0 0; + font-size: var(--text14); + color: var(--color-negative-red); + overflow-wrap: anywhere; +} + +.hint { + margin: calc(-1 * var(--sp8)) 0 var(--sp16); + font-size: var(--text12); + line-height: 1.5; + color: var(--color-grey-darker); +} diff --git a/src/views/Me/Settings/Personhood/Prove/index.tsx b/src/views/Me/Settings/Personhood/Prove/index.tsx new file mode 100644 index 0000000000..85f3df88e5 --- /dev/null +++ b/src/views/Me/Settings/Personhood/Prove/index.tsx @@ -0,0 +1,388 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { FormattedMessage, useIntl } from 'react-intl' + +import { Head, Layout, Spacer } from '~/components' + +import SettingsTabs from '../../SettingsTabs' +import settingsStyles from '../../styles.module.css' +import { + BROWSER_PROOF_ROUTE, + type BrowserProofHandoff, + clearBrowserProofHandoff, + ISOLATED_PROVER_ROUTE, + loadBrowserProofHandoff, +} from '../handoff' +import styles from './styles.module.css' + +type StorageEstimateReport = { + quota?: number + usage?: number + available?: number +} + +type BrowserProofReadiness = { + checkedAt: string + crossOriginIsolated: boolean + language: string + platform: string + secureContext: boolean + standalone: boolean + userAgent: string + checks: { + cacheStorage: boolean + decompressionStream: boolean + indexedDB: boolean + serviceWorker: boolean + sharedArrayBuffer: boolean + storageEstimate?: StorageEstimateReport + storageManager: boolean + webAssembly: boolean + worker: boolean + } +} + +const FEASIBILITY_ROUTE = '/me/settings/personhood/feasibility' + +const bytesToPayloadSize = (bytes?: number) => { + if (!bytes) { + return '0 B' + } + + if (bytes < 1024) { + return `${bytes} B` + } + + return `${Math.round(bytes / 1024)} KiB` +} + +const bytesToMiB = (bytes?: number) => { + if (!bytes) { + return '0 MiB' + } + + return `${Math.round(bytes / 1024 / 1024)} MiB` +} + +const encodedBytes = (value?: string) => + value ? new TextEncoder().encode(value).byteLength : 0 + +const createReadiness = async (): Promise => { + const nav = window.navigator as Navigator & { standalone?: boolean } + const storageEstimate = nav.storage?.estimate + ? await nav.storage.estimate() + : undefined + + return { + checkedAt: new Date().toISOString(), + crossOriginIsolated: window.crossOriginIsolated, + language: nav.language, + platform: nav.platform, + secureContext: window.isSecureContext, + standalone: + window.matchMedia('(display-mode: standalone)').matches || + nav.standalone === true, + userAgent: nav.userAgent, + checks: { + cacheStorage: 'caches' in window, + decompressionStream: 'DecompressionStream' in window, + indexedDB: 'indexedDB' in window, + serviceWorker: 'serviceWorker' in nav, + sharedArrayBuffer: typeof SharedArrayBuffer !== 'undefined', + storageEstimate: storageEstimate + ? { + available: + storageEstimate.quota && storageEstimate.usage + ? storageEstimate.quota - storageEstimate.usage + : undefined, + quota: storageEstimate.quota, + usage: storageEstimate.usage, + } + : undefined, + storageManager: !!nav.storage, + webAssembly: typeof WebAssembly !== 'undefined', + worker: typeof Worker !== 'undefined', + }, + } +} + +const getReadinessStatus = (readiness?: BrowserProofReadiness) => { + if (!readiness) { + return 'not checked' + } + + return readiness.secureContext && + readiness.crossOriginIsolated && + readiness.checks.webAssembly && + readiness.checks.worker && + readiness.checks.indexedDB && + readiness.checks.decompressionStream + ? 'ready' + : 'blocked' +} + +const getHandoffStatus = (handoff?: BrowserProofHandoff) => + handoff ? 'ready' : 'missing' + +const PersonhoodProve = () => { + const intl = useIntl() + const [handoff, setHandoff] = useState() + const [readiness, setReadiness] = useState() + const [running, setRunning] = useState(false) + const [copied, setCopied] = useState(false) + + const refreshReadiness = useCallback(async () => { + setRunning(true) + try { + setReadiness(await createReadiness()) + } finally { + setRunning(false) + } + }, []) + + useEffect(() => { + setHandoff(loadBrowserProofHandoff()) + refreshReadiness() + }, [refreshReadiness]) + + const reportText = useMemo( + () => + JSON.stringify( + { + handoff: handoff + ? { + appId: handoff.proofInput.appId, + certChainType: handoff.certChainType, + challenge: handoff.proofInput.challenge, + challengeExpiresAt: handoff.proofInput.challengeExpiresAt, + handoffExpiresAt: handoff.handoffExpiresAt, + savedAt: handoff.savedAt, + source: handoff.source, + version: handoff.version, + } + : undefined, + location: BROWSER_PROOF_ROUTE, + readiness, + }, + null, + 2 + ), + [handoff, readiness] + ) + + const copyReport = useCallback(async () => { + if (navigator.clipboard) { + await navigator.clipboard.writeText(reportText) + } + setCopied(true) + window.setTimeout(() => setCopied(false), 1600) + }, [reportText]) + + const clearHandoff = useCallback(() => { + clearBrowserProofHandoff() + setHandoff(undefined) + }, []) + + const returnToTwFido = useCallback(() => { + window.location.assign(handoff?.returnUrl || FEASIBILITY_ROUTE) + }, [handoff?.returnUrl]) + + const openIsolatedProver = useCallback(() => { + window.location.assign(ISOLATED_PROVER_ROUTE) + }, []) + + const readinessStatus = getReadinessStatus(readiness) + const storage = readiness?.checks.storageEstimate + + return ( + + + + + } + /> + + + + + +
+
+
+

+ +

+

+ +

+
+ +
+ + + + +
+ +
+
+
Handoff
+
{getHandoffStatus(handoff)}
+
+
+
Browser
+
{readinessStatus}
+
+
+
Secure context
+
{readiness?.secureContext ? 'yes' : 'no'}
+
+
+
Cross origin isolated
+
{readiness?.crossOriginIsolated ? 'yes' : 'no'}
+
+
+
SharedArrayBuffer
+
{readiness?.checks.sharedArrayBuffer ? 'yes' : 'no'}
+
+
+
DecompressionStream
+
{readiness?.checks.decompressionStream ? 'yes' : 'no'}
+
+
+
IndexedDB
+
{readiness?.checks.indexedDB ? 'yes' : 'no'}
+
+
+
Storage quota
+
{bytesToMiB(storage?.quota)}
+
+
+
APP_ID
+
{handoff?.proofInput.appId || 'missing'}
+
+
+
Challenge expires
+
{handoff?.proofInput.challengeExpiresAt || 'missing'}
+
+
+
Handoff expires
+
{handoff?.handoffExpiresAt || 'missing'}
+
+
+
Cert payload
+
+ {bytesToPayloadSize(encodedBytes(handoff?.proofInput.cert))} +
+
+
+
Signature payload
+
+ {bytesToPayloadSize( + encodedBytes(handoff?.proofInput.signedResponse) + )} +
+
+
+
Proof worker
+
pending
+
+
+ + {!handoff && ( +

+ +

+ )} + + {readinessStatus === 'blocked' && ( +

+ +

+ )} +
+ +
+
+

+ +

+

+ +

+
+ + +
+ +
{reportText}
+
+ + +
+ ) +} + +export default PersonhoodProve diff --git a/src/views/Me/Settings/Personhood/Prove/styles.module.css b/src/views/Me/Settings/Personhood/Prove/styles.module.css new file mode 100644 index 0000000000..bebba71ec9 --- /dev/null +++ b/src/views/Me/Settings/Personhood/Prove/styles.module.css @@ -0,0 +1,104 @@ +.panel { + padding: var(--sp16); + margin-bottom: var(--sp16); + border: 1px solid var(--color-grey-lighter); + border-radius: 8px; +} + +.header { + & h2 { + margin: 0 0 var(--sp8); + font-size: var(--text18); + font-weight: var(--font-semibold); + } + + & p { + margin: 0; + font-size: var(--text14); + color: var(--color-grey-darker); + } +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: var(--sp8); + margin: var(--sp16) 0; +} + +.button, +.buttonSecondary { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 112px; + min-height: 40px; + padding: 0 var(--sp16); + font-size: var(--text14); + font-weight: var(--font-medium); + text-align: center; + border-radius: 6px; +} + +.button { + color: var(--color-white); + background: var(--color-matters-green); +} + +.buttonSecondary { + color: var(--color-matters-green); + background: transparent; + border: 1px solid var(--color-matters-green); +} + +.button:disabled, +.buttonSecondary:disabled { + pointer-events: none; + cursor: not-allowed; + opacity: 0.5; +} + +.metrics { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: var(--sp8); + margin: 0 0 var(--sp16); + + & div { + padding: var(--sp12); + background: var(--color-grey-lighter); + border-radius: 6px; + } + + & dt { + font-size: var(--text12); + color: var(--color-grey-darker); + } + + & dd { + margin: var(--sp4) 0 0; + font-family: monospace; + font-size: var(--text14); + overflow-wrap: anywhere; + } +} + +.report { + max-height: 420px; + padding: var(--sp16); + margin: 0; + overflow: auto; + font-size: var(--text12); + line-height: 1.5; + color: var(--color-white); + white-space: pre-wrap; + background: var(--color-black); + border-radius: 6px; +} + +.error { + margin: var(--sp8) 0 0; + font-size: var(--text14); + color: var(--color-negative-red); + overflow-wrap: anywhere; +} diff --git a/src/views/Me/Settings/Personhood/handoff.ts b/src/views/Me/Settings/Personhood/handoff.ts new file mode 100644 index 0000000000..4d5d81e139 --- /dev/null +++ b/src/views/Me/Settings/Personhood/handoff.ts @@ -0,0 +1,199 @@ +export type PersonhoodProofInput = { + appId: string + cert: string + challenge: string + challengeExpiresAt?: string + signedResponse: string +} + +export type BrowserProofHandoff = { + certChainProvingKeyUrl: string + certChainType: 'rs4096' + handoffExpiresAt?: string + handoffToken: string + linkVerifyUrl: string + proofInput: PersonhoodProofInput + returnUrl: string + savedAt: string + smtSnapshotUrl: string + source: 'matters-web' + userSigProvingKeyUrl: string + version: 1 +} + +export const BROWSER_PROOF_ROUTE = '/me/settings/personhood/prove' +export const ISOLATED_PROVER_ROUTE = '/api/personhood/prover' +export const BROWSER_PROOF_HANDOFF_STORAGE_KEY = + 'matters.personhood.browserProofHandoff.v1' +export const BROWSER_PROOF_HANDOFF_FRAGMENT_KEY = 'personhood_handoff' + +export const CERT_CHAIN_PROVING_KEY_URL = + 'https://github.com/zkmopro/zkID/releases/download/latest/cert_chain_rs4096_proving.key.gz' +export const USER_SIG_PROVING_KEY_URL = + 'https://github.com/zkmopro/zkID/releases/download/latest/user_sig_rs2048_proving.key.gz' +export const SMT_SNAPSHOT_URL = + 'https://github.com/moven0831/moica-revocation-smt/releases/download/snapshot-latest/g3-tree-snapshot.json.gz' + +const BROWSER_PROOF_HANDOFF_MAX_AGE_MS = 15 * 60 * 1000 + +export const parseHandoffTime = (value?: string) => { + if (!value) { + return undefined + } + + const numeric = Number(value) + if (Number.isFinite(numeric)) { + return numeric > 1_000_000_000_000 ? numeric : numeric * 1000 + } + + const parsed = Date.parse(value) + return Number.isNaN(parsed) ? undefined : parsed +} + +const getBrowserProofHandoffStorages = () => { + if (typeof window === 'undefined') { + return [] + } + + return [window.sessionStorage, window.localStorage] +} + +export const isExpiredBrowserProofHandoff = (handoff: BrowserProofHandoff) => { + const savedAt = parseHandoffTime(handoff.savedAt) + const challengeExpiresAt = parseHandoffTime( + handoff.proofInput.challengeExpiresAt + ) + const handoffExpiresAt = parseHandoffTime(handoff.handoffExpiresAt) + const now = Date.now() + + return ( + (savedAt ? now - savedAt > BROWSER_PROOF_HANDOFF_MAX_AGE_MS : true) || + (challengeExpiresAt ? now > challengeExpiresAt : false) || + (handoffExpiresAt ? now > handoffExpiresAt : false) + ) +} + +export const buildBrowserProofHandoff = ({ + handoffExpiresAt, + handoffToken, + origin, + proofInput, + returnUrl, +}: { + handoffExpiresAt?: string + handoffToken: string + origin: string + proofInput: PersonhoodProofInput + returnUrl: string +}): BrowserProofHandoff => ({ + certChainProvingKeyUrl: CERT_CHAIN_PROVING_KEY_URL, + certChainType: 'rs4096', + handoffExpiresAt, + handoffToken, + linkVerifyUrl: `${origin}/api/personhood/zkid/link-verify`, + proofInput, + returnUrl, + savedAt: new Date().toISOString(), + smtSnapshotUrl: SMT_SNAPSHOT_URL, + source: 'matters-web', + userSigProvingKeyUrl: USER_SIG_PROVING_KEY_URL, + version: 1, +}) + +export const saveBrowserProofHandoff = (handoff: BrowserProofHandoff) => { + if (typeof window === 'undefined') { + return + } + + const value = JSON.stringify(handoff) + for (const storage of getBrowserProofHandoffStorages()) { + try { + storage.setItem(BROWSER_PROOF_HANDOFF_STORAGE_KEY, value) + } catch { + // Keep navigation usable if one storage backend is unavailable. + } + } +} + +const bytesToBase64Url = (bytes: Uint8Array) => { + let binary = '' + const chunkSize = 0x8000 + for (let i = 0; i < bytes.length; i += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize)) + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +const base64UrlToBytes = (value: string) => { + const normalized = value.replace(/-/g, '+').replace(/_/g, '/') + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=') + const raw = atob(padded) + const bytes = new Uint8Array(raw.length) + for (let i = 0; i < raw.length; i += 1) { + bytes[i] = raw.charCodeAt(i) + } + return bytes +} + +export const encodeBrowserProofHandoff = (handoff: BrowserProofHandoff) => + bytesToBase64Url(new TextEncoder().encode(JSON.stringify(handoff))) + +export const decodeBrowserProofHandoff = (encoded: string) => + JSON.parse( + new TextDecoder().decode(base64UrlToBytes(encoded)) + ) as BrowserProofHandoff + +export const buildIsolatedProverUrl = ({ + handoff, + origin, +}: { + handoff: BrowserProofHandoff + origin: string +}) => + `${origin}${ISOLATED_PROVER_ROUTE}#${BROWSER_PROOF_HANDOFF_FRAGMENT_KEY}=${encodeBrowserProofHandoff(handoff)}` + +export const loadBrowserProofHandoff = () => { + if (typeof window === 'undefined') { + return undefined + } + + for (const storage of getBrowserProofHandoffStorages()) { + try { + const raw = storage.getItem(BROWSER_PROOF_HANDOFF_STORAGE_KEY) + if (!raw) { + continue + } + + const handoff = JSON.parse(raw) as BrowserProofHandoff + if (!handoff.proofInput || !handoff.handoffToken) { + storage.removeItem(BROWSER_PROOF_HANDOFF_STORAGE_KEY) + continue + } + + if (isExpiredBrowserProofHandoff(handoff)) { + storage.removeItem(BROWSER_PROOF_HANDOFF_STORAGE_KEY) + continue + } + + return handoff + } catch { + storage.removeItem(BROWSER_PROOF_HANDOFF_STORAGE_KEY) + } + } + + return undefined +} + +export const clearBrowserProofHandoff = () => { + if (typeof window === 'undefined') { + return + } + + for (const storage of getBrowserProofHandoffStorages()) { + try { + storage.removeItem(BROWSER_PROOF_HANDOFF_STORAGE_KEY) + } catch { + // Ignore storage cleanup failures. + } + } +} diff --git a/src/views/ToS/Term/privacy.ts b/src/views/ToS/Term/privacy.ts index 1f7ddce63f..a970ee6b19 100644 --- a/src/views/ToS/Term/privacy.ts +++ b/src/views/ToS/Term/privacy.ts @@ -5,12 +5,12 @@ const Privacy: { zh_hant: string; zh_hans: string; en: string } = {

隱私政策

-

更新日期:2020 年 7 月 18 日

-

本隱私政策(隱私政策)最後更新於上述日期,並且將不時更新。本隱私政策的任何變更將於公佈本政策的修訂政策後生效。本隱私政策旨在告知所有用戶,MATTERS LAB LIMITED(以下簡稱「MATTERS」)將如何處理個人信息與非個人信息。如有用戶不同意本隱私政策中的任何一部分,則MATTERS將無法向此類用戶提供服務,而此類用戶也應當停止訪問服務。

+

更新日期:2026 年 5 月 1 日

+

本隱私政策(隱私政策)最後更新於上述日期,並且將不時更新。本隱私政策的任何變更將於公佈本政策的修訂政策後生效。本隱私政策旨在告知所有用戶,MATTERS LAB HOLDINGS CORPORATION (以下簡稱「MATTERS」)將如何處理個人信息與非個人信息。如有用戶不同意本隱私政策中的任何一部分,則MATTERS將無法向此類用戶提供服務,而此類用戶也應當停止訪問服務。

1. 定義

所有術語的定義都在用戶協議中列出,除非在此另有定義:

-

MATTERS, INC. 是指負責收集、保存、處理或使用個人信息的公司;

+

MATTERS LAB HOLDINGS CORPORATION, 是指一家根據英屬維爾京群島(BVI)法律註冊成立,負責收集、保存、處理或使用個人信息的公司;

GDPR 的定義參見本隱私政策第9.2條;

@@ -57,22 +57,22 @@ const Privacy: { zh_hant: string; zh_hans: string; en: string } = {

5. 個人信息的公開與傳輸

-

5.1 MATTERS可以向其附屬公司提供某些特定的個人信息,用於本隱私政策第4.2條中規定的目的,其附屬公司可以位美國境內或者境外,並且均受本隱私政策的約束。

+

5.1 MATTERS可以向其附屬公司提供某些特定的個人信息,用於本隱私政策第4.2條中規定的目的,其附屬公司可以位英屬維爾京群島境內或者境外,並且均受本隱私政策的約束。

-

5.2 MATTERS可以向位於美國境內或境外的以下人員提供某些特定的個人信息:(i)數據儲存服務提供商,目的僅限於儲存MATTERS不時收集的數據;(ii)戰略合作夥伴,包括但不限於(a)郵件公司和電子郵件服務提供商,目的僅限於郵寄和發送廣告宣傳資料;(b)托管與數據庫管理服務提供商,包括但不限於星際文件系統IPFS;(iii)服務供應商和MATTERS指定提供服務的其他第三方,包括但不限於於谷歌雲端服務、亞馬遜雲端服務,虛擬貨幣運營商等根據合同規定所有第三方(x)禁止將個人信息用於其合同中規定的用途以外的任何目的,以及保留個人信息時間長於其合同中規定用途所需的時間;(y)應當阻止未經授權或意外的訪問、處理、刪除、丟失或使用個人信息。一旦發現個人信息不準確,應當通知第三方,並且提供詳情,以便第三方能夠更正個人信息。個人信息不會出於營銷目的與第三方共享。

+

5.2 MATTERS可以向位於英屬維爾京群島境內或境外的以下人員提供某些特定的個人信息:(i)數據儲存服務提供商,目的僅限於儲存MATTERS不時收集的數據;(ii)戰略合作夥伴,包括但不限於(a)郵件公司和電子郵件服務提供商,目的僅限於郵寄和發送廣告宣傳資料;(b)托管與數據庫管理服務提供商,包括但不限於星際文件系統IPFS;(iii)服務供應商和MATTERS指定提供服務的其他第三方,包括但不限於於谷歌雲端服務、亞馬遜雲端服務,虛擬貨幣運營商等根據合同規定所有第三方(x)禁止將個人信息用於其合同中規定的用途以外的任何目的,以及保留個人信息時間長於其合同中規定用途所需的時間;(y)應當阻止未經授權或意外的訪問、處理、刪除、丟失或使用個人信息。一旦發現個人信息不準確,應當通知第三方,並且提供詳情,以便第三方能夠更正個人信息。個人信息不會出於營銷目的與第三方共享。

5.3 根據本隱私政策第5條,用戶一旦接受本隱私政策,則代表其承認、理解並同意,他或她的個人信息可能會被公開或轉移給附屬公司和/或任何此類第三方(和其員工與代表)。

-

5.4 當MATTERS重組其組織結構或改變其管理或業務合作時,每位用戶的個人信息可由MATTERS基於本隱私政策或者其他隱私權聲明(需要告知每一位用戶)自行決定轉移給接任數據控制工作或者提供類似服務的第三方。在與此類收購與重組相關時,此類第三方可以位於美國外,並於美國外使用用戶個人信息。

+

5.4 當MATTERS重組其組織結構或改變其管理或業務合作時,每位用戶的個人信息可由MATTERS基於本隱私政策或者其他隱私權聲明(需要告知每一位用戶)自行決定轉移給接任數據控制工作或者提供類似服務的第三方。在與此類收購與重組相關時,此類第三方可以位於英屬維爾京群島以外,並於該處使用用戶個人信息。

6. 電子郵件、廣告和取消訂閱

6.1 只有當用戶提供了適用法律要求的同意書時(內容表明他們特別明確地為了接收直接營銷信息,希望從MATTERS處接收廣告內容,並且向MATTERS提供他們的聯繫方式),MATTERS才可以使用此用戶的個人信息與此用戶聯繫,並提供此用戶可能感興趣的商品和服務信息(單獨,或者與MATTERS的附屬公司或合作夥伴提供的功能或服務和應用一起)。

-

6.2 MATTERS給予其用戶取消所有推廣信息訂閱的權利。MATTERS應在每一封推廣營銷的電子郵件中,向用戶提供取消未來訂閱的選項。另外,用戶也可以在任何時候通過發送郵件給 <ask@matters.town>.來取消訂閱推廣內容,不需要向MATTERS付費。

+

6.2 MATTERS給予其用戶取消所有推廣信息訂閱的權利。MATTERS應在每一封推廣營銷的電子郵件中,向用戶提供取消未來訂閱的選項。另外,用戶也可以在任何時候通過發送郵件給 <hi@matters.town>.來取消訂閱推廣內容,不需要向MATTERS付費。

-

6.3 MATTERS給予其用戶請求從其數據存儲中刪除他們個人信息的權利。用戶可以通過發送電子郵件至 <ask@matters.town> 的方式書面提出請求。為避免疑義,MATTERS有權無限期地、出於任何目的地保留、處理和使用任何非個人信息。

+

6.3 MATTERS給予其用戶請求從其數據存儲中刪除他們個人信息的權利。用戶可以通過發送電子郵件至 <hi@matters.town> 的方式書面提出請求。為避免疑義,MATTERS有權無限期地、出於任何目的地保留、處理和使用任何非個人信息。

7. 第三方社交媒體網站的隱私政策

@@ -112,12 +112,12 @@ const Privacy: { zh_hant: string; zh_hans: string; en: string } = {

隐私政策

-

更新日期:2020 年 7 月 18 日

-

本隐私政策(隐私政策)最后更新于上述日期,并且将不时更新。本隐私政策的任何变更将于公布本政策的修订政策后生效。本隐私政策旨在告知所有用户,MATTERS LAB LIMITED(以下简称「MATTERS」)将如何处理个人信息与非个人信息。如有用户不同意本隐私政策中的任何一部分,则MATTERS将无法向此类用户提供服务,而此类用户也应当停止访问服务。

+

更新日期:2026 年 5 月 1 日

+

本隐私政策(隐私政策)最后更新于上述日期,并且将不时更新。本隐私政策的任何变更将于公布本政策的修订政策后生效。本隐私政策旨在告知所有用户,MATTERS LAB HOLDINGS CORPORATION (以下简称「MATTERS」)将如何处理个人信息与非个人信息。如有用户不同意本隐私政策中的任何一部分,则MATTERS将无法向此类用户提供服务,而此类用户也应当停止访问服务。

1. 定义

所有术语的定义都在用户协议中列出,除非在此另有定义:

-

MATTERS, INC. 是指负责收集、保存、处理或使用个人信息的公司;

+

MATTERS LAB HOLDINGS CORPORATION, 是指一家根据英属维尔京群岛(BVI)法律注册成立,负责收集、保存、处理或使用个人信息的公司;

GDPR 的定义参见本隐私政策第9.2条;

@@ -164,22 +164,22 @@ const Privacy: { zh_hant: string; zh_hans: string; en: string } = {

5. 个人信息的公开与传输

-

5.1 MATTERS可以向其附属公司提供某些特定的个人信息,用于本隐私权政策第4.2条中规定的目的,其附属公司可以位于美国境内或者境外,并且均受本隐私权政策的约束。

+

5.1 MATTERS可以向其附属公司提供某些特定的个人信息,用于本隐私权政策第4.2条中规定的目的,其附属公司可以位于英属维尔京群岛境内或者境外,并且均受本隐私权政策的约束。

-

5.2 MATTERS可以向位于美国境内或境外的以下人员提供某些特定的个人信息:(i)数据储存服务提供商,目的仅限于储存MATTERS不时收集的数据;(ii)战略合作伙伴,包括但不限于(a)邮件公司和电子邮件服务提供商,目的仅限于邮寄和发送广告宣传资料;(b)托管与数据库管理服务提供商,包括但不限于星际文件系统IPFS;(iii)服务供应商和MATTERS指定提供服务的其他第三方,包括但不限于谷歌云端服务、亚马逊云端服务、虚拟货币运营商等,根据合同规定所有第三方(x)禁止将个人信息用于其合同中规定的用途以外的任何目的,以及保留个人信息时间长于其合同中规定用途所需的时间;(y)应当阻止未经授权或意外的访问、处理、删除、丢失或使用个人信息。一旦发现个人信息不准确,应当通知第三方,并且提供详情,以便第三方能够更正个人信息。个人信息不会出于营销目的与第三方共享。

+

5.2 MATTERS可以向位于英属维尔京群岛境内或境外的以下人员提供某些特定的个人信息:(i)数据储存服务提供商,目的仅限于储存MATTERS不时收集的数据;(ii)战略合作伙伴,包括但不限于(a)邮件公司和电子邮件服务提供商,目的仅限于邮寄和发送广告宣传资料;(b)托管与数据库管理服务提供商,包括但不限于星际文件系统IPFS;(iii)服务供应商和MATTERS指定提供服务的其他第三方,包括但不限于谷歌云端服务、亚马逊云端服务、虚拟货币运营商等,根据合同规定所有第三方(x)禁止将个人信息用于其合同中规定的用途以外的任何目的,以及保留个人信息时间长于其合同中规定用途所需的时间;(y)应当阻止未经授权或意外的访问、处理、删除、丢失或使用个人信息。一旦发现个人信息不准确,应当通知第三方,并且提供详情,以便第三方能够更正个人信息。个人信息不会出于营销目的与第三方共享。

5.3 根据本隐私政策第5条,用户一旦接受本隐私政策,则代表其承认、理解并同意,他或她的个人信息可能会被公开或转移给附属公司和/或任何此类第三方(和其员工与代表)。

-

5.4 当MATTERS重组其组织结构或改变其管理或业务合作时,每位用户的个人信息可由MATTERS基于本隐私权政策或者其他隐私权声明(需要告知每一位用户)自行决定转移给接任数据控制工作或者提供类似服务的第三方。在与此类收购与重组相关时,此类第三方可以位于美国外,并于美国外使用用户个人信息。

+

5.4 当MATTERS重组其组织结构或改变其管理或业务合作时,每位用户的个人信息可由MATTERS基于本隐私权政策或者其他隐私权声明(需要告知每一位用户)自行决定转移给接任数据控制工作或者提供类似服务的第三方。在与此类收购与重组相关时,此类第三方可以位于英属维尔京群岛以外,并于该处使用用户个人信息。

6. 电子邮件、广告和取消订阅

6.1 只有当用户提供了适用法律要求的同意书时(内容表明他们特别明确地为了接收直接营销信息,希望从MATTERS处接收广告内容,并且向MATTERS提供他们的联系方式),MATTERS才可以使用此用户的个人信息与此用户联系,并提供此用户可能感兴趣的商品和服务信息(单独,或者与MATTERS的附属公司或合作伙伴提供的功能或服务和应用一起)。

-

6.2 MATTERS给予其用户取消所有推广信息订阅的权利。MATTERS应在每一封推广营销的电子邮件中,向用户提供取消未来订阅的选项。另外,用户也可以在任何时候通过发送邮件给 <ask@matters.town>.来取消订阅推广内容,不需要向MATTERS付费。

+

6.2 MATTERS给予其用户取消所有推广信息订阅的权利。MATTERS应在每一封推广营销的电子邮件中,向用户提供取消未来订阅的选项。另外,用户也可以在任何时候通过发送邮件给 <hi@matters.town>.来取消订阅推广内容,不需要向MATTERS付费。

-

6.3 MATTERS给予其用户请求从其数据存储中删除他们个人信息的权利。用户可以通过发送电子邮件至 <ask@matters.town> 的方式书面提出请求。为避免疑义,MATTERS有权无限期地、出于任何目的地保留、处理和使用任何非个人信息。

+

6.3 MATTERS给予其用户请求从其数据存储中删除他们个人信息的权利。用户可以通过发送电子邮件至 <hi@matters.town> 的方式书面提出请求。为避免疑义,MATTERS有权无限期地、出于任何目的地保留、处理和使用任何非个人信息。

7. 第三方社交媒体网站的隐私政策

@@ -219,12 +219,12 @@ const Privacy: { zh_hant: string; zh_hans: string; en: string } = {

Privacy Policy

-

Last updated: 18 July, 2020

+

Last updated: 1 May, 2026

This privacy policy (the Privacy Policy) was last updated on the date above and shall be updated from time to time. Any changes to this Privacy Policy will become effective upon posting of the revised policy hereunder. This Privacy Policy is intended to inform all Users about how the Data Controller treats Personal Information and Non-personal Information. If any User does not agree with any part of this Privacy Policy, then the Data Controller cannot provide the Services to such User, and such User should stop accessing the same.

1. DEFINITIONS

Capitalized terms shall be as defined in the terms and conditions unless otherwise herein defined:

-

Data Controller mean Matters, Inc., the company responsible for the collection, holding, processing or use of Personal Information;

+

Data Controller mean Matters Lab Holdings Corporation, the company responsible for the collection, holding, processing or use of Personal Information;

GDPR is as defined in clause 9.2 of this Privacy Policy;

@@ -242,7 +242,7 @@ const Privacy: { zh_hant: string; zh_hans: string; en: string } = {

3.1 The Data Controller will receive, store and process Personal Information that the Users make available when assessing or using the Services. It will take appropriate steps to protect Personal Information collected and/or held by it against unauthorized or accidental access, processing, erasure, loss, use or disclosure.

-

3.2 In order to protect the Personal Information, the Data Controller will require all Users (or their respective relevant persons as defined under applicable laws) to prove their identities in relation to their requests to access and/or correct their Personal Information. Requests for access and correction of Personal Information are to be addressed in writing and sent to <ask@matters.town>. A reasonable fee shall be charged to offset the Data Controller’s administrative and actual costs incurred in complying with the relevant data access requests. Where there are reasonable grounds for believing that any Personal Information is inaccurate, the Data Controller shall take practicable steps to ensure that the Personal Information shall not be used unless and until those grounds cease to be applicable to such Personal Information or the Personal Information shall be erased.

+

3.2 In order to protect the Personal Information, the Data Controller will require all Users (or their respective relevant persons as defined under applicable laws) to prove their identities in relation to their requests to access and/or correct their Personal Information. Requests for access and correction of Personal Information are to be addressed in writing and sent to <hi@matters.town>. A reasonable fee shall be charged to offset the Data Controller’s administrative and actual costs incurred in complying with the relevant data access requests. Where there are reasonable grounds for believing that any Personal Information is inaccurate, the Data Controller shall take practicable steps to ensure that the Personal Information shall not be used unless and until those grounds cease to be applicable to such Personal Information or the Personal Information shall be erased.

3.3 Where any Personal Information held by the Data Controller is no longer required for the purposes as stated under clause 4.2 of this Privacy Policy, the Data Controller shall take practicable steps to cease processing such Personal Information as soon as reasonably practicable, provided that the Data Controller may keep copies of such Personal Information as is reasonably required (i) for archival purposes; (ii) for use in relation to any actual or potential dispute; (iii) for compliance with applicable laws and regulations; (iv) for enforcing any agreement the Data Controller has with such User; and (v) for protecting the Data Controller’s and its employees’ rights, property or safety. The Data Controller will take practicable steps to ensure such Personal Information will not be kept longer than is necessary for the fulfillment of the above purposes (including direct or indirect purposes).

@@ -271,22 +271,22 @@ const Privacy: { zh_hant: string; zh_hans: string; en: string } = {

5. DISCLOSURE AND TRANSFER OF PERSONAL INFORMATION

-

5.1 The Data Controller may make certain Personal Information available to its Affiliates for the purposes as stated under clause 4.2 of this Privacy Policy, who may be situated within or outside the United States and all of whom are bound by this Privacy Policy.

+

5.1 The Data Controller may make certain Personal Information available to its Affiliates for the purposes as stated under clause 4.2 of this Privacy Policy, who may be situated within or outside the British Virgin Islands and all of whom are bound by this Privacy Policy.

-

5.2 The Data Controller may make certain Personal Information available to below persons, who may be situated within or outside the United States: (i) data storage service providers, for the sole purpose of storing data which the Data Controller collected from time to time; (ii) strategic business partners, including but not limited to (a) mail houses and email service providers, for the sole purpose of mailing and dissemination of its promotional materials; and (b) hosting and database management service providers, including but not limited to IPFS; and (iii) suppliers of the Services and other third parties appointed by the Data Controller to perform the Services, including but not limited to Google Cloud Service, Amazon Web Service, cryptocurrency operator, all of whom are contractually (x) prohibited from using the Personal Information for any purpose other than for the purpose(s) specified in their respective contracts and keeping Personal Information longer than is necessary for the fulfillment of such purpose(s) specified in their respective contracts; and (y) required to prevent unauthorized or accidental access, processing, erasure, loss or use of the Personal Information. Such third parties shall be informed if the Personal Information is discovered to be inaccurate and shall be provided with such particulars as will enable such third party to correct the Personal Information having regard to such purpose(s). Personal Information will not be shared with third parties for their own marketing purposes.

+

5.2 The Data Controller may make certain Personal Information available to below persons, who may be situated within or outside the British Virgin Islands: (i) data storage service providers, for the sole purpose of storing data which the Data Controller collected from time to time; (ii) strategic business partners, including but not limited to (a) mail houses and email service providers, for the sole purpose of mailing and dissemination of its promotional materials; and (b) hosting and database management service providers, including but not limited to IPFS; and (iii) suppliers of the Services and other third parties appointed by the Data Controller to perform the Services, including but not limited to Google Cloud Service, Amazon Web Service, cryptocurrency operator, all of whom are contractually (x) prohibited from using the Personal Information for any purpose other than for the purpose(s) specified in their respective contracts and keeping Personal Information longer than is necessary for the fulfillment of such purpose(s) specified in their respective contracts; and (y) required to prevent unauthorized or accidental access, processing, erasure, loss or use of the Personal Information. Such third parties shall be informed if the Personal Information is discovered to be inaccurate and shall be provided with such particulars as will enable such third party to correct the Personal Information having regard to such purpose(s). Personal Information will not be shared with third parties for their own marketing purposes.

5.3 By accepting this Privacy Policy, each User acknowledges, understands and agrees that his or her Personal Information may be disclosed or transferred to Affiliates and/or any such third parties (and their respective employees and representatives) under clause 5 of this Privacy Policy.

-

5.4 In the circumstances where the Data Controller reorganizes its group structure or undergoes a change of control or business combination, each User’s Personal Information may, at the Data Controller’s sole discretion, be transferred to a third party who will continue to operate the Data Controller or a similar service under either this Privacy Policy or a different privacy policy statement which will be notified to each User. Such a third party may be located, and use of Users’ Personal Information may be made, outside of the United States in connection with such acquisition or reorganization.

+

5.4 In the circumstances where the Data Controller reorganizes its group structure or undergoes a change of control or business combination, each User’s Personal Information may, at the Data Controller’s sole discretion, be transferred to a third party who will continue to operate the Data Controller or a similar service under either this Privacy Policy or a different privacy policy statement which will be notified to each User. Such a third party may be located, and use of Users’ Personal Information may be made, outside of the British Virgin Islands in connection with such acquisition or reorganization.

6. EMAILS AND PROMOTIONS AND OPTING OUT

6.1 Only when Users have provided the consents required under applicable laws (including indicating that they would like to receive promotional materials from the Data Controller and providing their contact details to the Data Controller specifically and expressly in order to receive direct marketing communications), the Data Controller may use the Personal Information of such Users to contact such Users and provide information about goods and services (either alone or in conjunction with products or services offered by the Data Controller’s Affiliates or business partners) that may be of interest to such Users.

-

6.2 The Data Controller provides its Users with the ability to unsubscribe from all marketing communications. Every time a User receives a direct marketing email, he/she will be provided with the choice to opt-out of future direct marketing emails. Users may also opt-out of receiving promotional materials by sending an email to <ask@matters.town> or at any time, without charge by the Data Controller.

+

6.2 The Data Controller provides its Users with the ability to unsubscribe from all marketing communications. Every time a User receives a direct marketing email, he/she will be provided with the choice to opt-out of future direct marketing emails. Users may also opt-out of receiving promotional materials by sending an email to <hi@matters.town> or at any time, without charge by the Data Controller.

-

6.3 The Data Controller provides its Users with the ability to request removal of their Personal Information from its storage. Users may lodge such request in writing by sending an email to <ask@matters.town>. For the avoidance of doubt, the Data Controller is entitled to retain, process and use, for an indefinite term and any purpose, any Non-personal Information.

+

6.3 The Data Controller provides its Users with the ability to request removal of their Personal Information from its storage. Users may lodge such request in writing by sending an email to <hi@matters.town>. For the avoidance of doubt, the Data Controller is entitled to retain, process and use, for an indefinite term and any purpose, any Non-personal Information.

7. THIRD-PARTY SOCIAL MEDIA SITES’ PRIVACY POLICIES

diff --git a/src/views/ToS/Term/tos.ts b/src/views/ToS/Term/tos.ts index 3dc37583b8..3d09c6f8b7 100644 --- a/src/views/ToS/Term/tos.ts +++ b/src/views/ToS/Term/tos.ts @@ -5,7 +5,7 @@ const ToS: { zh_hant: string; zh_hans: string; en: string } = {

用戶協議

-

更新日期:2020 年 7 月 18 日

+

更新日期:2026 年 5 月 1 日

請仔細閱讀以下條款,這些條款包含了有關您合法權利和義務的重要信息。當您訪問或使用MATTERS時,即表示您完全接受以下條款的約束。如果您不同意這些條款,可以選擇不進入 MATTERS 。此中文條款為英文版本譯本,如中、英文兩個版本有任何抵觸或不相符之處,請以後面的英文版本為準。

關於條款修改:

MATTERS 保留自行修改條款的權利。修改後,MATTERS將通過平台公佈其修改,或以其它方式向您告知。如果您在MATTERS公佈修改後繼續訪問和使用MATTERS,則表示您同意接受修改後的條款。如果您不同意修改後的條款,您只能停止使用平台和服務。

@@ -19,7 +19,7 @@ const ToS: { zh_hant: string; zh_hans: string; en: string } = {

    (e)    內容標準是指任何用戶在平台發布的任何內容的標準,詳見本條款第5.5條;

    (f)    知識產權是指與平台相關的所有版權和其他知識產權,無論其以何種形式呈現、為哪種媒體形式體現、是否已注冊,包括但不限於專利、設計權、商標、服務商標、應用程序、前述任何一項的申請權利、數據庫權利、專有技術、商業名稱、機密信息權利、商譽以及世界範圍內存在的其他類似的權利;

    (g)    專有技術是指與平台和/或服務相關的所有技術、經驗、數據、技術和商業信息,包括但不限於運營模式;

-

    (h)    MATTERS是指 MATTERS, INC.,一家基於美國特拉華州法律注冊成立的私營有限公司;

+

    (h)    MATTERS是指 MATTERS LAB HOLDINGS CORPORATION,一家基於英屬維爾京群島(British Virgin Islands)法律注冊成立的有限責任公司;

    (i)    MATTERS庫是指MATTERS用於記錄和共享所有內容的可公開訪問的分佈式數據庫;

    (j)    在線課程是指由MATTERS組織、安排或以其他方式提供給平台上用戶的在線課程;

    (k)    密碼是指註冊用戶對其帳戶和第三方貨幣帳戶設置的所有此類(i)密碼;(ii)為其儲值錢包設置的個人密碼;

@@ -65,29 +65,30 @@ const ToS: { zh_hant: string; zh_hans: string; en: string } = {

    (i)    通過您的帳戶進入的儲值錢包。您可以根據條款在儲值錢包中進行儲值,付款和提現;

    (ii)    MATTERS會為您生成一個與一對私鑰(參見術語私鑰)和公鑰鏈接的加密貨幣錢包,這對私鑰和公鑰僅提供給您一人並且完全由您擁有。

3.4 您了解並同意您使用的儲值錢包,第三方服務商提供的加密貨幣錢包,第三方貨幣帳戶和第三方貨幣將受此類條款約束且第三方服務提供商可能不時會收取服務費用。

+

3.5 您可以使用加密貨幣錢包,如 MetaMask、WalletConnect 或任何其他兼容的錢包,來訪問我們的服務。登入時需要通過您的錢包確認身份。我們既不儲存您的私鑰,也不訪問您錢包中的資金。我們不擁有或控制任何錢包提供商(例如 MetaMask、WalletConnect)、區塊鏈網絡,或通過平台使用的任何第三方服務。我們對第三方功能或您在平台上的交易或互動所導致的任何損害不承擔責任。您的錢包地址在平台上進行交易或發布內容時將公開。

帳戶安全

-

3.5 您有責任維護帳戶、密碼、第三方貨幣帳戶和私鑰各個方面的安全。您接受並承認,如果您沒有密碼和/或密鑰,您將無法訪問與您帳戶相關聯的儲值錢包、任何用戶內容和第三方貨幣帳戶。您同意您的帳戶屬於您個人,並且同意您不會向其他任何人提供訪問平台所需要的用戶名、密碼、私鑰、第三方貨幣帳戶信息或其他安全信息。您必須將這些信息嚴格保密,不得將其透露給其他任何人或實體。

-

3.6 您同意,當您發現帳戶、儲值錢包、第三方貨幣帳戶被未經授權訪問或使用,或者有其他安全問題時立即通知MATTERS。在此您同意,在適用法律允許的最大範圍內,您對任何在您的帳戶、密碼、第三方貨幣帳戶和私鑰下發生的活動(包括與您帳戶鏈接第三方貨幣及其帳戶)負責,並接受任何授權的或未授權的對您帳戶、儲值錢包(包括儲值金額)和第三方貨幣帳戶和私鑰的訪問(包括與您帳戶鏈接第三方貨幣及其帳戶)可能帶來的所有風險。

-

3.7 您理解並同意數字虛擬錢包和密碼學是不斷進步的領域。代碼破解的進步或技術的進步(比如量子計算機)可能會對平台、服務、帳戶、儲值錢包、第三方貨幣帳戶造成風險,導致您的財產被盜或丟失。在使用平台和/或服務時,您承認並接受此類內在風險。

+

3.6 您有責任維護帳戶、密碼、第三方貨幣帳戶和私鑰各個方面的安全。您接受並承認,如果您沒有密碼和/或密鑰,您將無法訪問與您帳戶相關聯的儲值錢包、任何用戶內容和第三方貨幣帳戶。您同意您的帳戶屬於您個人,並且同意您不會向其他任何人提供訪問平台所需要的用戶名、密碼、私鑰、第三方貨幣帳戶信息或其他安全信息。您必須將這些信息嚴格保密,不得將其透露給其他任何人或實體。

+

3.7 您同意,當您發現帳戶、儲值錢包、第三方貨幣帳戶被未經授權訪問或使用,或者有其他安全問題時立即通知MATTERS。在此您同意,在適用法律允許的最大範圍內,您對任何在您的帳戶、密碼、第三方貨幣帳戶和私鑰下發生的活動(包括與您帳戶鏈接第三方貨幣及其帳戶)負責,並接受任何授權的或未授權的對您帳戶、儲值錢包(包括儲值金額)和第三方貨幣帳戶和私鑰的訪問(包括與您帳戶鏈接第三方貨幣及其帳戶)可能帶來的所有風險。

+

3.8 您理解並同意數字虛擬錢包和密碼學是不斷進步的領域。代碼破解的進步或技術的進步(比如量子計算機)可能會對平台、服務、帳戶、儲值錢包、第三方貨幣帳戶造成風險,導致您的財產被盜或丟失。在使用平台和/或服務時,您承認並接受此類內在風險。

私鑰的保管

-

3.8 MATTERS無法訪問且不會接收或存儲您的私鑰,因此對任何和所有此類私鑰的安全保存和/或管理不承擔任何責任。

+

3.9 MATTERS無法訪問且不會接收或存儲您的私鑰,因此對任何和所有此類私鑰的安全保存和/或管理不承擔任何責任。

儲值錢包

-

3.9 關於儲值錢包中的儲值和提現,您了解並同意:

+

3.10 關於儲值錢包中的儲值和提現,您了解並同意:

    (a)    MATTERS可能會根據實際情況和適用法律,對儲值錢包設置最小和最大的儲值、支付和提現額度。如有此類設置,MATTERS會告知您。如有疑慮,您可以將您儲值錢包中的錢提出;

    (b)    MATTERS會通過第三方服務商來執行您對儲值錢包的儲值和提現管理。建議您仔細閱讀第三方服務商的服務條款以了解他們對每次儲值和提現操作的要求和需收取的費用。您同意(i)授權我們指定的第三方服務商根據這些條款提供您儲值和提現的服務;(ii)儲值和提現服務是由第三方服務商提供,不在MATTERS的控制和責任範圍內;

    (c)    MATTERS有權向您收取儲值錢包中儲值金額百分之十五(15%)的行政服務費,費用比例會由MATTERS決定。您同意,MATTERS有權在第三方服務商進行提現服務之前,從您的儲值金額中扣除適用的行政管理費;

    (d)    您同意,MATTERS會認為從您帳戶發出的有關儲值錢包指令都是來自您的操作並且會根據這些指令採取行動。您還同意,如果未收到正確授權指令或發生任何其他違反安全性規定的情況,MATTERS和指定的第三方支付提供商有權不處理或延遲處理這樣的指示。您同意豁免MATTERS對與因本條款引起的訴訟,索賠,損失,成本和費用的責任。

-

3.10 關於您對儲值錢包的使用和義務,您理解並同意:

+

3.11 關於您對儲值錢包的使用和義務,您理解並同意:

    (a)    您不得以下列任何方式使用您的儲值錢包:(i)違反本條款或違反任何適用法律(包括但不限於與反洗錢活動有關的任何法律和法規);(ii)可能產生對MATTERS,其他用戶或第三方的投訴,糾紛,索賠,處罰或其他責任; (iii)可能導致MATTERS違反或不遵守任何此類適用法律;(iv)可能損壞,或讓平台的服務以及安全保障失效、承擔過多負擔,以及干擾其他用戶;

    (b)    您不得以任何方式篡改儲值錢包(包括但不限於儲值錢包中涉及的軟件,應用程序,功能和數據)。如果您的儲值錢包被篡改,MATTERS將不會接受任何交易或允許提現;

    (c)    如果您懷疑有未經您授權的交易或有人使用您的儲值錢包,您必須盡快通知MATTERS;

    (d)    在適當情況下,當警方要調查涉嫌未經授權的使用,或者您的儲值錢包被攻擊或被篡改,或者當MATTERS在合理或適用法律要求的理由下懷疑有可疑行為或使用模式,您會與MATTERS合作。

-

3.11 關於您儲值錢包中的儲值金額,您理解並同意:

+

3.12 關於您儲值錢包中的儲值金額,您理解並同意:

    (a)    僅當您提供正確的儲值錢包密碼後,MATTERS才能處理您儲值錢包中的付款或提取現金的指示;

    (b)    MATTERS在得到您的指示後會立即處理付款。因此,一旦MATTERS收到付款指示,您就無法取消或更改。無論什麼情況,平台上的儲值和交易都是不可取消的,並且相關的儲值也不可退款。對於因您錯誤的指示而給您造成的損失,MATTERS不承擔責任;

    (c)    儲值不會產生利息,沒有到期日,並且不可轉讓。

@@ -125,6 +126,7 @@ const ToS: { zh_hant: string; zh_hans: string; en: string } = {

隱私政策

4.10 您同意,您通過平台和/或使用平台和/或服務時向MATTERS提供的所有信息受隱私政策的約束,並且您准許MATTERS在遵守隱私政策的範圍內就您的信息採取任何行動。

+

4.11 如果您通過第三方服務(例如 X(前稱 Twitter)、Google、Facebook、Instagram、RSS)連接、連結或登錄到 MATTERS,您將指示該服務向我們發送信息,例如您在該服務的註冊信息、電子郵件、ID 和個人資料信息,或者根據您在該服務的隱私設置所授權的信息。

5. 平台上的內容

@@ -200,7 +202,7 @@ const ToS: { zh_hant: string; zh_hans: string; en: string } = {

6.7 其他產品以及平台上明確身份的公司名稱可以是MATTERS、其許可方和/或其關聯公司或第三方的名稱、商標、商號、服務商標、商標、標誌、其他專有名稱或標記。在平台上使用任何第三方的或歸屬於第三方的名稱、商標、商標、服務商標、商標、標誌或其他專有名稱或商標,以及能夠通過平台從第三方獲得特定商品或服務,均不可被解釋為此第三方受到平台的認可,贊助平台,或此第三方通過平台參與提供商品、服務或信息。

有關侵權的申訴

-

6.8 如果您認為有任何用戶內容侵犯了您的版權,請發送電子郵件至 <ask@matters.town>,我們將指示您,如何向我們發送侵犯版權的通知。MATTERS將審核並處理所有符合要求的通知。

+

6.8 如果您認為有任何用戶內容侵犯了您的版權,請發送電子郵件至 <hi@matters.town>,我們將指示您,如何向我們發送侵犯版權的通知。MATTERS將審核並處理所有符合要求的通知。

6.9 為避免疑義,MATTERS不對第三方提供的版權資料或第三方的知識產權侵權行為承擔任何責任。

7. 帳戶終止

@@ -227,7 +229,7 @@ const ToS: { zh_hant: string; zh_hans: string; en: string } = {

9.2 此外,相關適用的法律有可能不允許MATTERS限制或免除上述責任。在這樣的情況下,MATTERS應在相關法律規定的範圍內承擔相應的責任。在引起索賠的事件發生後,申訴方應盡快提交索賠申請。

9.3 您接受並承認,使用互聯網平台和服務存在風險,包括但不限於硬件故障、軟件故障、互聯網連接故障的風險,惡意軟件植入的風險,以及第三方有可能未經授權訪問存儲在您帳戶、第三方貨幣帳戶,儲值帳戶或與您帳戶相關聯的信息(包括但不限於您的密碼和私鑰)的風險。您接受並承認,MATTERS對於您使用服務時可能遇到的任何通信故障、中斷、錯誤、失真或延遲不承擔責任。

9.4 根據適用法規關於時效的規定,被過分延遲提交的索賠請求可能會被視作無效。只有真實的索賠才能得到處理。儘管有上述規定,任何由本條款、平台和/或服務引起的或與本條款、平台和/或服務相關的任何訴訟或索賠請求必須在訴因產生後一(1)年內被提交,否則在此期限過後此訴訟或索賠將被永久禁止。

-

9.5 在不影響上述規定的情況下以及在適用法律允許的情況下,MATTERS對任何一位用戶的累計賠償責任均不得超過150美金。

+

9.5 在不影響上述規定的情況下以及在適用法律允許的情況下,MATTERS對任何一位用戶的累計賠償責任均不得超過150美金 (USD)。

10. 賠償保障

10.1 您同意賠償和豁免MATTERS所有因為您的作為、不作為或疏忽而引致或遭受的任何索賠、責任、損害、損失和金錢賠償的責任,包括但不限於由以下情況(或內容)引起或與以下情況(或內容)相關的MATTERS、其關聯公司、其各自的許可方、服務提供商、員工、代理人、主管和/或董事被要求支付的、蒙受的、或被強加的法律及其他費用:

@@ -244,10 +246,10 @@ const ToS: { zh_hant: string; zh_hans: string; en: string } = {

12.2 未經MATTERS事先書面許可,您不得借法律的實施或以其他方式轉讓或轉移這些條款。MATTERS有權自行決定轉讓或轉移這些條款,並不受任何限制。在上述規定的規限下,本條款將對您、MATTERS、您與MATTERS各自的繼承人、被許可的受讓人和法律代表的利益具有適用性和約束力。

12.3 在此條款下任何被許可或被要求的通知或其他消息,包括有關修改本條款的通知,將由MATTERS以書面形式通過電子郵件發送。

12.4 MATTERS未行使本條款的任何權利或規定並不構成MATTERS對在未來行使該權利或條款的放棄。任何此類權利或規定的放棄只有在以書面形式並由正式授權的MATTERS代表簽字時才有效。除非本條款明確規定,否則任何一方根據本條款實施的任何救濟方法將不影響本條款下的其他救濟方法。

-

12.5 本條款受特拉華州法律管轄,並按照特拉華州法律解釋。本條款的各方不可撤回地同意,特拉華州法院對與本條款有關或由本條款引起的任何索賠或爭議擁有專屬管轄權。

+

12.5 本條款受英屬維爾京群島(British Virgin Islands)法律管轄,並按照英屬維爾京群島法律解釋。本條款的各方不可撤回地同意,英屬維爾京群島法院對與本條款有關或由本條款引起的任何索賠或爭議擁有專屬管轄權。

12.6 本條款和條件的原始英文版本可能被翻譯成其他語言。如果對本條款的內容或解釋存在爭議,亦或者本條款的英文版本與任何其他語言版本之間存在矛盾或不一致,則在法律允許的範圍內,英語版本適用、優先並具有決定性。

12.7 本條款應被視為可分割的。如有任何條款被確定為不可執行的或無效的,則應依然在適用法律允許的最大範圍內執行此條款,並且任何條款被確定無效都不應影響任何其他條款的有效性和可執行性。被分割的條款應由盡可能接近原先措辭和意圖的新條款取代。

-

12.8 與平台有關的所有投訴、反饋、評論、技術支持請求和其他聯絡信息請直接發送至 <ask@matters.town> 。

+

12.8 與平台有關的所有投訴、反饋、評論、技術支持請求和其他聯絡信息請直接發送至 <hi@matters.town> 。

`, @@ -257,7 +259,7 @@ const ToS: { zh_hant: string; zh_hans: string; en: string } = {

用户协议

-

更新日期:2020 年 7 月 18 日

+

更新日期:2026 年 5 月 1 日

请仔细阅读以下条款,这些条款包含了有关您合法权利和义务的重要信息。当您访问或使用MATTERS时,即表示您完全接受以下条款的约束。如果您不同意这些条款,可以选择不进入MATTERS。此中文条款为英文版本译本,如中、英文两个版本有任何抵触或不相符之处,请以后面的英文版本为准。

关于条款修改:

MATTERS 保留自行修改条款的权利。修改后,MATTERS将通过平台公布其修改,或以其它方式向您告知。如果您在MATTERS公佈修改后继续访问和使用MATTERS,则表示您同意接受修改后的条款。如果您不同意修改后的条款,您只能停止使用平台和服务。

@@ -271,7 +273,7 @@ const ToS: { zh_hant: string; zh_hans: string; en: string } = {

    (e)    内容标准是指任何用户在平台发布的任何内容的标准,详见本条款第5.5条;

    (f)    知识产权是指与平台相关的所有版权和其他知识产权,无论其以何种形式呈现、为哪种媒体形式体现、是否已注册,包括但不限于专利、设计权、商标、服务商标、应用程序、前述任何一项的申请权利、数据库权利、专有技术、商业名称、机密信息权利、商誉以及世界范围内存在的其他类似的权利;

    (g)    专有技术是指与平台和/或服务相关的所有技术、经验、数据、技术和商业信息,包括但不限于运营模式;

-

    (h)    MATTERS是指 MATTERS, INC.,一家基于美国特拉华州法律注册成立的私营有限公司;

+

    (h)    MATTERS是指 MATTERS LAB HOLDINGS CORPORATION,一家基于英属维尔京群岛(British Virgin Islands)法律注册成立的有限责任公司;

    (i)    MATTERS库是指MATTERS用于记录和共享所有内容的可公开访问的分布式数据库;

    (j)    在线课程是指由MATTERS组织、安排或以其他方式提供给平台上用户的在线课程;

    (k)    密码指註册用户对其帐户和第三方货币帐户设置的所有此类(i)密码;(ii)为其储值钱包设置的个人密码;

@@ -315,28 +317,29 @@ const ToS: { zh_hant: string; zh_hans: string; en: string } = {

    (i)    通过您的帐户进入的储值钱包。您可以根据条款在储值钱包中进行储值,付款和提现;

    (ii)    MATTERS会为您生成一个与一对私钥(参见术语私钥)和公钥链接的加密货币钱包,这对私钥和公钥仅提供给您一人并且完全由您拥有。

3.4 您了解并同意您使用的储值钱包,第三方服务商提供的加密货币钱包,第三方货币帐户和第三方货币将受此类条款约束且第三方服务提供商可能不时会收取服务费用。

+

3.5 您可以使用加密货币钱包,如 MetaMask、WalletConnect 或任何其他兼容的钱包,来访问我们的服务。登入时需要通过您的钱包确认身份。我们既不储存您的私钥,也不访问您钱包中的资金。我们不拥有或控制任何钱包提供商(例如 MetaMask、WalletConnect)、区块链网络,或通过平台使用的任何第三方服务。我们对第三方功能或您在平台上的交易或互动所导致的任何损害不承担责任。您的钱包地址在平台上进行交易或发布内容时将公开。

帳戶安全

-

3.5 您有责任维护帐戶、密码、第三方货币帐户和私钥各个方面的安全。您必须确保密码和私钥的安全。您接受并承认,如果您没有密码和/或密钥,您将无法访问与您帐戶相关联的储值钱包任何用户内容和加密货币(如适用)。您承认您的帐戶属于您个人,并且同意您不会向其他任何人提供访问平台所需要的用户名、密码、私钥或其他安全信息。您必须将这些信息严格保密,不得将其透露给其他任何人或实体。

-

3.6 您同意,当您发现帐户、储值钱包、第三方货币帐户被未经授权访问或使用,或者有其他安全问题时立即通知MATTERS。在此您同意,在适用法律允许的最大范围内,您对任何在您的帐户、密码、第三方货币帐户和私钥下發生的活动(包括与您帐户链接第三方货币及其帐户)负责,并接受任何授权的或未授权的对您帐户、储值钱包(包括储值金额)和第三方货币帐户和私钥的访问(包括与您帐户链接第三方货币及其帐户)可能带来的所有风险。

-

3.7 您理解并同意数字虚拟钱包和密码学是不断进步的领域。代码破解的进步或技术的进步(比如量子计算机)可能会对平台、服务和用户帐戶、储值钱包、第三方货币帐户造成风险,导致您的财产被盗或丢失。在使用平台和/或服务时,您承认并接受此类内在风险。

+

3.6 您有责任维护帐戶、密码、第三方货币帐户和私钥各个方面的安全。您必须确保密码和私钥的安全。您接受并承认,如果您没有密码和/或密钥,您将无法访问与您帐戶相关联的储值钱包任何用户内容和加密货币(如适用)。您承认您的帐戶属于您个人,并且同意您不会向其他任何人提供访问平台所需要的用户名、密码、私钥或其他安全信息。您必须将这些信息严格保密,不得将其透露给其他任何人或实体。

+

3.7 您同意,当您发现帐户、储值钱包、第三方货币帐户被未经授权访问或使用,或者有其他安全问题时立即通知MATTERS。在此您同意,在适用法律允许的最大范围内,您对任何在您的帐户、密码、第三方货币帐户和私钥下發生的活动(包括与您帐户链接第三方货币及其帐户)负责,并接受任何授权的或未授权的对您帐户、储值钱包(包括储值金额)和第三方货币帐户和私钥的访问(包括与您帐户链接第三方货币及其帐户)可能带来的所有风险。

+

3.8 您理解并同意数字虚拟钱包和密码学是不断进步的领域。代码破解的进步或技术的进步(比如量子计算机)可能会对平台、服务和用户帐戶、储值钱包、第三方货币帐户造成风险,导致您的财产被盗或丢失。在使用平台和/或服务时,您承认并接受此类内在风险。

私钥的保管

-

3.8 MATTERS无法访问且不会接收或存储您的私钥,因此对任何和所有此类私钥的安全保存和/或管理不承担任何责任。

+

3.9 MATTERS无法访问且不会接收或存储您的私钥,因此对任何和所有此类私钥的安全保存和/或管理不承担任何责任。

-

3.9 关于储值钱包中的储值和提现,您了解并同意:

+

3.10 关于储值钱包中的储值和提现,您了解并同意:

    (a)    MATTERS可能会根据实际情况和适用法律,对储值钱包设置最小和最大的储值、支付和提现额度。如有此类设置,MATTERS会告知您。如有疑虑,您可以将储值钱包中的钱提出;

    (b)    MATTERS会通过第三方服务商来执行您对储值钱包的储值和提现管理。建议您仔细阅读第三方服务商的服务条款以了解他们对每次储值和提现操作的要求和需收取的费用。您同意(i)授权我们指定的第三方服务商根据这些条款提供您储值和提现的服务;(ii)储值和提现服务是由第三方服务商提供,不在MATTERS的控制和责任范围内;

    (c)    MATTERS有权向您收取储值钱包中储值金额百分之十五(15%)的行政服务费,费用比例会由MATTERS决定。您同意,MATTERS有权在第三方服务商进行提现服务之前,从您的储值金额中扣除适用的行政管理费;

    (d)    您同意,MATTERS会认为从您帐户發出的有关储值钱包指令都是来自您的操作并且会根据这些指令采取行动。您还同意,如果未收到正确授权指令或发生任何其他违反安全性规定的情况,MATTERS和指定的第三方支付提供商有权不处理或延迟处理这样的指示。您同意豁免MATTERS对与因本条款引起的诉讼,索赔,损失,成本和费用的责任。

-

3.10 关于您对储值钱包的使用和义务,您理解并同意:

+

3.11 关于您对储值钱包的使用和义务,您理解并同意:

    (a)    您不得以下列任何方式使用您的储值钱包:(i)违反本条款或违反任何适用法律(包括但不限于与反洗钱活动有关的任何法律和法规);(ii)可能产生对MATTERS,其他用户或第三方的投诉,纠纷,索赔,处罚或其他责任; (iii)可能导致MATTERS违反或不遵守任何此类适用法律;(iv)可能损坏,或让平台的服务以及安全保障失效、承担过多负担,以及干扰其他用户;

    (b)    您不得以任何方式篡改储值钱包(包括但不限于储值钱包中涉及的软件,应用程序,功能和数据)。如果您的储值钱包被篡改,MATTERS将不会接受任何交易或允许提现;

    (c)    如果您怀疑有未经您授权的交易或有人使用您的储值钱包,您必须尽快通知MATTERS;

    (d)    在适当情况下,当警方要调查涉嫌未经授权的使用,或者您的储值钱包被攻击或被篡改,或者当MATTERS在合理或适用法律要求的理由下怀疑有可疑行为或使用模式,您会与MATTERS合作。

-

3.11 关于您储值钱包中的储值金额,您理解并同意:

+

3.12 关于您储值钱包中的储值金额,您理解并同意:

    (a)    仅当您提供正确的储值钱包密码后,MATTERS才能处理您储值钱包中的付款或提取现金的指示;

    (b)    MATTERS在得到您的指示后会立即处理付款。因此,一旦MATTERS收到付款指示,您就无法取消或更改。无论什麽情况,平台上的储值和交易都是不可取消的,并且相关的储值也不可退款。对于因您错误的指示而给您造成的损失,MATTERS不承担责任。

    (c)    储值不会产生利息,没有到期日,并且不可转让。

@@ -374,6 +377,7 @@ const ToS: { zh_hant: string; zh_hans: string; en: string } = {

隐私政策

4.10 您同意,您通过平台和/或使用平台和/或服务时向MATTERS提供的所有信息受隐私政策的约束,并且您准许MATTERS在遵守隐私政策的范围内就您的信息采取任何行动。

+

4.11 如果您通过第三方服务(例如 X(前称 Twitter)、Google、Facebook、Instagram、RSS)连接、链接或登录到 MATTERS,您将指示该服务向我们发送信息,例如您在该服务的注册信息、电子邮件、ID 和个人资料信息,或者根据您在该服务的隐私设置所授权的信息。

5. 平台上的内容

@@ -449,7 +453,7 @@ const ToS: { zh_hant: string; zh_hans: string; en: string } = {

6.7 其他产品以及平台上明确身份的公司名称可以是MATTERS、其许可方和/或其关联公司或第三方的名称、商标、商号、服务商标、商标、标志、其他专有名称或标记。在平台上使用任何第三方的或归属于第三方的名称、商标、商标、服务商标、商标、标志或其他专有名称或商标,以及能够通过平台从第三方获得特定商品或服务,均不可被解释为此第三方受到平台的认可,赞助平台,或此第三方通过平台参与提供商品、服务或信息。

有关侵权的申诉

-

6.8 如果您认为有任何用户内容侵犯了您的版权,请发送电子邮件至 <ask@matters.town> ,我们将指示您,如何向我们发送侵犯版权的通知。MATTERS将审核并处理所有符合要求的通知。

+

6.8 如果您认为有任何用户内容侵犯了您的版权,请发送电子邮件至 <hi@matters.town> ,我们将指示您,如何向我们发送侵犯版权的通知。MATTERS将审核并处理所有符合要求的通知。

6.9 为避免疑义,MATTERS不对第三方提供的版权资料或第三方的知识产权侵权行为承担任何责任。

7. 帳戶终止

@@ -476,7 +480,7 @@ const ToS: { zh_hant: string; zh_hans: string; en: string } = {

9.2 此外,相关适用的法律有可能不允许MATTERS限制或免除上述责任。在这样的情况下,MATTERS应在相关法律规定的范围内承担相应的责任。在引起索赔的事件发生后,申诉方应尽快提交索赔申请。

9.3 您接受并承认,使用互联网平台和服务存在风险,包括但不限于硬件故障、软件故障和互联网连接故障的风险,恶意软件植入的风险,以及第三方有可能未经授权访问存储在您帐戶、第三方货币帐户、储值帐户内或与您帐戶相关联的信息(包括但不限于您的密码和私钥)的风险。您接受并承认,MATTERS对于您使用服务时可能遇到的任何通信故障、中断、错误、失真或延迟不承担责任。

9.4 根据适用法规关于时效的规定,被过分延迟提交的索赔请求可能会被视作无效。只有真实的索赔才能得到处理。尽管有上述规定,任何由本条款、平台和/或服务引起的或与本条款、平台和/或服务相关的任何诉讼或索赔请求必须在诉因产生后一(1)年内被提交,否则在此期限过后此诉讼或索赔将被永久禁止。

-

9.5 在不影响上述规定的情况下以及在适用法律允许的情况下,MATTERS对任何一位用户的累计赔偿责任在任何情况下均不得超过150美金。

+

9.5 在不影响上述规定的情况下以及在适用法律允许的情况下,MATTERS对任何一位用户的累计赔偿责任在任何情况下均不得超过150美金 (USD)。

10. 赔偿保障

10.1 您同意赔偿和豁免MATTERS所有因为您的作为、不作为或疏忽而引致或遭受的任何索赔、责任、损害、损失和金钱赔偿的责任,包括但不限于由以下情况(或内容)引起或与以下情况(或内容)相关的MATTERS、其关联公司、其各自的许可方、服务提供商、员工、代理人、主管和/或董事被要求支付的、蒙受的、或被强加的法律及其他费用:

@@ -492,10 +496,10 @@ const ToS: { zh_hant: string; zh_hans: string; en: string } = {

12.2 未经MATTERS事先书面许可,您不得借法律的实施或以其他方式转让或转移这些条款。MATTERS有权自行决定转让或转移这些条款,并不受任何限制。在上述规定的规限下,本条款将对您、MATTERS、您与MATTERS各自的继承人、被许可的受让人和法律代表的利益具有适用性和约束力。

12.3 在此条款下任何被许可或被要求的通知或其他消息,包括有关修改本条款的通知,将由MATTERS以书面形式通过电子邮件发送。

12.4 MATTERS未行使本条款的任何权利或规定并不构成MATTERS对在未来行使该权利或条款的放弃。任何此类权利或规定的放弃只有在以书面形式并由正式授权的MATTERS代表签字时才有效。除非本条款明确规定,否则任何一方根据本条款实施的任何救济方法将不影响本条款下的其他救济方法。

-

12.5 本条款受特拉华州法律管辖,并按照特拉华州法律解释。本条款的各方不可撤回地同意,特拉华州法院对与本条款有关或由本条款引起的任何索赔或争议拥有专属管辖权。

+

12.5 本条款受英属维尔京群岛(British Virgin Islands)法律管辖,并按照英属维尔京群岛法律解释。本条款的各方不可撤回地同意,英属维尔京群岛法院对与本条款有关或由本条款引起的任何索赔或争议拥有专属管辖权。

12.6 本条款和条件的原始英文版本可能被翻译成其他语言。如果对本条款的内容或解释存在争议,亦或者本条款的英文版本与任何其他语言版本之间存在矛盾或不一致,则在法律允许的范围内,英语版本适用、优先并具有决定性。

12.7 本条款应被视为可分割的。如有任何条款被确定为不可执行的或无效的,则应依然在适用法律允许的最大范围内执行此条款,并且任何条款被确定无效都不应影响任何其他条款的有效性和可执行性。被分割的条款应由尽可能接近原先措辞和意图的新条款取代。

-

12.8 与平台有关的所有投诉、反馈、评论、技术支持请求和其他联络信息请直接发送至 <ask@matters.town> 。

+

12.8 与平台有关的所有投诉、反馈、评论、技术支持请求和其他联络信息请直接发送至 <hi@matters.town> 。

`, @@ -505,7 +509,7 @@ const ToS: { zh_hant: string; zh_hans: string; en: string } = {

Terms and Conditions for MATTERS

-

Last updated date: 18 July, 2020

+

Last updated date: 1 May, 2026

PLEASE READ THESE TERMS CAREFULLY AS THEY CONTAIN IMPORTANT INFORMATION REGARDING YOUR LEGAL RIGHTS, REMEDIES AND OBLIGATIONS. YOU ACKNOWLEDGE AND AGREE THAT, BY ACCESSING OR USING THE PLATFORM AND/OR THE SERVICES, YOU ARE INDICATING THAT YOU HAVE READ, AND THAT YOU UNDERSTAND AND AGREE TO BE BOUND BY THESE TERMS. IF YOU DO NOT AGREE TO THESE TERMS, THEN YOU HAVE NO RIGHT TO ACCESS OR USE THE PLATFORM AND/OR THE SERVICES.

Modification:

MATTERS reserves the right to, at its sole discretion, modify these Terms at any time without prior notice and consent. If MATTERS modifies these Terms, it will post the modification via the Platform or otherwise provide you with notice of the modification. By continuing to access and/or use the Platform and/or the Services after MATTERS has posted a modification via the Platform and/or has provided you with notice of the modification, you are indicating that you agree to be bound by the modified Terms. If the modified Terms are not acceptable to you, your only recourse is to cease using the Platform and the Services.

@@ -519,7 +523,7 @@ const ToS: { zh_hant: string; zh_hans: string; en: string } = {

    (e)    Content Standards means the standards for any Contents posted by any User to the Platform as set out in clause 5.5 of these Terms;

    (f)    Intellectual Property Rightsincludes, in relation to the Platform, all copyright and other intellectual property rights, howsoever arising and in whatever media, whether registered or not registered, including but not limited to patents, design rights, trademarks, service marks, applications or rights to apply for any of the foregoing, database rights, Know-How, trade or business name, rights in confidential information, goodwill, and other similar rights existing in any part of the world;

    (g)    Know-How means all know-how, experience, data, technical and commercial information relating to the Platform and/or the Services, including but not limited to mode of operation;

-

    (h)    MATTERS means MATTERS, INC., a private limited company incorporated under the laws of the State of Delaware;

+

    (h)    MATTERS means Matters Lab Holdings Corporation, a private limited company incorporated under the laws of the British Virgin Islands;

    (i)    MATTERS Vaultmeans the publicly accessible distributed database which MATTERS uses to record and share all Contents;

    (j)    Online Courses means such online courses organized, arranged or otherwise made available by MATTERS to the Users on the Platform;

    (k)    Passcode means, any and all such (i) passcodes set by a registered User in relation to his/her Account and Third Party Coins ID respectively; and (ii) the Stored Value Wallet PIN set by a registered User for his/her Stored Value Wallet;

@@ -566,28 +570,30 @@ const ToS: { zh_hant: string; zh_hans: string; en: string } = {

3.4 You understand and agree that your use of the Stored Value Wallet, the third party service provider cryptocurrency wallet, the Third Party Coins ID and the Third Party Coins will be subject to such terms and conditions and/or applicable fees as imposed by the relevant third party service providers from time to time.

+

3.5 You may use a Crypto Wallet, such as MetaMask, WalletConnect, or any other compatible wallet, to access our services. Identity confirmation is required via your Wallet at login. We neither store your private keys nor access your wallet's funds. We do not own or control any Wallet providers (e.g., MetaMask, WalletConnect), blockchain networks, or third-party services used via the Platform. We are not liable for Third Party Functionality or any resulting damages from your transactions or interactions on the Platform. Your Wallet address becomes public during transactions or content publication on the Platform.

+

Account security

-

3.5 You are responsible for maintaining all aspects of security of your Account, your Third Party Coins ID, your Passcode and your Private Key. You must keep your Passcode and your Private Key secure. You accept and acknowledge that your Stored Value Wallet, User Contents and Third Party Coins you have associated with your Account and your Third Party Coins ID will become permanently inaccessible if you do not have your Passcode and/or your Private Key. You acknowledge that your Account, your Passcode, and your Third Party Coins ID are personal to you and agree not to provide any other person with access to the Platform using your User name, Passcode, Private Key or other security information. You must treat such information as strictly confidential, and you must not disclose it to any other person or entity.

-

3.6 You agree to notify MATTERS immediately of any unauthorized access to or use of your Account, your Third Party Coins ID or any other breach of security. You hereby accept and acknowledge that you take responsibility for all activities that occur under your Account, your Third Party Coins ID (including the Third Party Coins linked to your Account and your Third Party Coins ID) and Private Key and accept all risks of any authorized or unauthorized access to your Account, your Third Party Coins ID (including the Third Party Coins linked to your Account and your Third Party Coins ID) and Private Key, to the maximum extent permitted by applicable laws.

-

3.7 You acknowledge and understand that cryptography is a progressing field. Advances in code cracking or technical advances such as the development of quantum computers may present risks to the Platform, the Services, your Account and your Third Party Coins ID, which could result in the theft or loss of your property. By using the Platform and/or the Services, you acknowledge and accept such inherent risks.

+

3.6 You are responsible for maintaining all aspects of security of your Account, your Third Party Coins ID, your Passcode and your Private Key. You must keep your Passcode and your Private Key secure. You accept and acknowledge that your Stored Value Wallet, User Contents and Third Party Coins you have associated with your Account and your Third Party Coins ID will become permanently inaccessible if you do not have your Passcode and/or your Private Key. You acknowledge that your Account, your Passcode, and your Third Party Coins ID are personal to you and agree not to provide any other person with access to the Platform using your User name, Passcode, Private Key or other security information. You must treat such information as strictly confidential, and you must not disclose it to any other person or entity.

+

3.7 You agree to notify MATTERS immediately of any unauthorized access to or use of your Account, your Third Party Coins ID or any other breach of security. You hereby accept and acknowledge that you take responsibility for all activities that occur under your Account, your Third Party Coins ID (including the Third Party Coins linked to your Account and your Third Party Coins ID) and Private Key and accept all risks of any authorized or unauthorized access to your Account, your Third Party Coins ID (including the Third Party Coins linked to your Account and your Third Party Coins ID) and Private Key, to the maximum extent permitted by applicable laws.

+

3.8 You acknowledge and understand that cryptography is a progressing field. Advances in code cracking or technical advances such as the development of quantum computers may present risks to the Platform, the Services, your Account and your Third Party Coins ID, which could result in the theft or loss of your property. By using the Platform and/or the Services, you acknowledge and accept such inherent risks.

Safekeeping of Private Keys

-

3.8 As and where applicable, MATTERS has no access and does not receive or store your Private Keys and therefore assume no responsibility for the safekeeping and/or management of any and all such Private Keys.

+

3.9 As and where applicable, MATTERS has no access and does not receive or store your Private Keys and therefore assume no responsibility for the safekeeping and/or management of any and all such Private Keys.

Stored Value Wallet

-

3.9 With regards to the top-up and cash-out of Stored Value in your Stored Value Wallet, you understand and agree that:

+

3.10 With regards to the top-up and cash-out of Stored Value in your Stored Value Wallet, you understand and agree that:

    (a)    MATTERS may impose limitations and shall notify you from time to time the minimum and maximum permitted amount for top-up, payment and/or cash-out of Stored Value in your Stored Value Wallet over any given period of time in compliant with MATTERS’ internal policies and/or any applicable laws. For the avoidance of doubt, you may cash-out the Stored Value in your Stored Value Wallet at any time;

    (b)    MATTERS will, in its sole discretion, designate and procure third party service provider(s) to manage the top-up and cash-out of Stored Value in your Stored Value Wallet in accordance with your instructions from time to time. You are advised to read the terms and conditions of these third party service providers carefully to understand the requirements, fees and charges they may impose per top-up and cash-out transaction of your Stored Value (or part thereof as applicable). You agree (i) to authorize our designated third party service provider(s) to effect any such top-up and cash-out transactions of your Stored Value in accordance with these Terms; and (ii) that such top-up and cash-out transactions of Stored Value is effected by third party service provider(s) beyond the control and responsibilities of MATTERS;

    (c)    MATTERS shall have a right to charge you an administrative fee equivalent to fifteen per cent (15%) of the relevant cash-out amount of Stored Value in your Stored Value Wallet. The applicable administration fees are determined by MATTERS at its sole discretion from time to time. You agree that MATTERS shall have the right to deduct the applicable administration fees from your Stored Value when applicable before the relevant cash-out transaction by the third party service provider(s); and

    (d)    you agree that MATTERS shall be entitled to assume that any instruction to deal with Stored Value in your Stored Value Wallet which is received by MATTERS from your Account through the Platform is an instruction given by you and can accordingly be acted on by MATTERS. You further agree where MATTERS and/or our designated third party payment system provider(s) has reason to believe that an instruction is not properly authorized or that any other breach of security has occurred, MATTERS has the right not to process, or delay processing such instruction. You agree to indemnify MATTERS and hold MATTERS harmless against all actions, claims, proceedings, losses, damages, costs and expenses which may be brought against us or suffered or incurred by MATTERS arising from or in connection with these Terms.

-

3.10 With regards to your use and obligations of the Stored Value Wallet, you understand and agree that:

+

3.11 With regards to your use and obligations of the Stored Value Wallet, you understand and agree that:

    (a)    you must not use your Stored Value Wallet in any manner that (i) is inconsistent with these Terms or in contravention of any applicable laws (including but not limited to any laws and regulations relating to anti-money laundering activities); (ii) could result in complaints, disputes, claims, penalties or other liability to MATTERS, other Users or third parties; (iii) could cause MATTERS to breach or not comply with any such applicable laws; and/or (iv) could damage, disable, overburden, impair or compromise the Services, the Platform or security or interfere with other Users;

    (b)    you must not tamper with your Stored Value Wallet (including, but not limited to, the software, applications, functions, features and the data recorded on your Stored Value Wallet) in any way. For the avoidance of doubt, MATTERS will not honor transactions, or allow cash-out of any Stored Value, if your Stored Value Wallet has been tampered with;

    (c)    you must notify MATTERS as soon as practicable if you suspect that there has been unauthorized transaction and/or access to your Stored Value Wallet; and

    (d)    you will cooperate with MATTERS when MATTERS ask for your co-operation and, if appropriate, the police, in investigating any possible or suspected unauthorized use, or in resetting your Stored Value Wallet if it is hacked or tampered with, or if MATTERS has reasonable grounds to suspect suspicious behavior or usage pattern, or as required by applicable laws.

-

3.11 With regards to the Stored Value in your Stored Value Wallet, you understand and agree that:

+

3.12 With regards to the Stored Value in your Stored Value Wallet, you understand and agree that:

    (a)    MATTERS will only process your instructions to make payments with and/or cash-out Stored Value from your Stored Value Wallet after it has been provided with the relevant accurate Stored Value Wallet PIN;

    (b)    MATTERS process your instructions to make payments on the Platform by way of Stored Value with immediate effect. Accordingly, you cannot cancel or change any payment(s) instruction once the same has been received by MATTERS. Payment transactions of Stored Value on the Platform are non-cancellable and relevant Stored Value is non-refundable under any circumstances. MATTERS is not responsible for, and will not be held liable for any loss suffered by you due to incorrect payment instruction; and

    (c)    Stored Value is interest-free, with no expiration date and is non-transferable.

@@ -626,6 +632,9 @@ const ToS: { zh_hant: string; zh_hans: string; en: string } = {

Privacy Policy

4.10 You agree that all information you provide to MATTERS through the Platform and/or in connection with the use of the Platform and/or the Services is governed by the Privacy Policy, and you consent to all actions MATTERS takes with respect to your information consistent with the Privacy Policy.

+

4.11 If you link, connect, or login to MATTERS with a third party service (e.g. X(former Twitter),Google, Facebook, Instagram, RSS), you direct the service to send us information such as your registration, emails, ID and profile informations controlled by that service or as authorized by you via your privacy settings at that service. +

+

5. CONTENTS OF THE PLATFORM

User Contents

@@ -702,7 +711,7 @@ const ToS: { zh_hant: string; zh_hans: string; en: string } = {

6.7 Other product and company names identified on the Platform may be the name, trade mark, trade name, service mark, logo, symbol or other proprietary designation of MATTERS, its licensors and/or Affiliates or a third party. The use on the Platform of any name, trade name, trade mark, service mark, logo, symbol or other proprietary designation or marking of or belonging to any third party, and the availability of specific goods or services from such third party through the Platform, should not be construed as an endorsement or sponsorship of the Platform by any such third party, or the participation by such third party in the offering of goods, services or information through the Platform.

Complaint of alleged infringement

-

6.8 If you believe that any User Contents violate your copyright, please contact us at <ask@matters.town> for instructions on sending us a notice of copyright infringement. MATTERS will review and address all notices that comply with the requirements.

+

6.8 If you believe that any User Contents violate your copyright, please contact us at <hi@matters.town> for instructions on sending us a notice of copyright infringement. MATTERS will review and address all notices that comply with the requirements.

6.9 For the avoidance of doubt, MATTERS does not assume any liability for copyrighted materials provided by third parties or any Intellectual Property Rights infringements by such third parties.

7. ACCOUNT TERMINATION

@@ -746,10 +755,10 @@ const ToS: { zh_hant: string; zh_hans: string; en: string } = {

12.2 You may not assign or transfer these Terms, by operation of law or otherwise, without MATTERS’ prior written consent. MATTERS may assign or transfer these Terms, at its sole discretion, without restriction. Subject to the foregoing, these Terms will bind and inure to the benefit of you and MATTERS, the successors, permitted assigns and legal representatives of you and MATTERS respectively.

12.3 Any notices or other communications permitted or required hereunder, including those regarding modifications to these Terms, will be in writing and given by MATTERS via email transmitted.

12.4 The failure of MATTERS to enforce any right or provision of these Terms will not constitute a waiver of future enforcement of that right or provision. The waiver of any such right or provision will be effective only if in writing and signed by a duly authorized representative of MATTERS. Except as expressly set forth in these Terms, the exercise by either party of any of its remedies under these Terms will be without prejudice to its other remedies thereunder.

-

12.5 These Terms shall be governed by and construed in accordance with the laws of the State of Delaware. The Parties hereunder irrevocably agrees that the courts of the State of Delaware shall have exclusive jurisdiction in relation to any claim or dispute concerning or arising from these Terms.

+

12.5 These Terms shall be governed by and construed in accordance with the laws of the British Virgin Islands. The Parties hereunder irrevocably agree that the courts of the British Virgin Islands shall have exclusive jurisdiction in relation to any claim or dispute concerning or arising from these Terms.

12.6 The original English version of these terms and conditions may have been translated into other languages. In the event of a dispute about the contents or interpretation of these Terms or inconsistency or discrepancy between the English version and any other language version of these Terms, the English language version to the extent permitted by law shall apply, prevail and be conclusive.

12.7 These Terms shall be deemed severable. In the event that any provision is determined to be unenforceable or invalid, such provision shall nonetheless be enforced to the fullest extent permitted by applicable law and such determination shall not affect the validity and enforceability of any other remaining provisions. The severed provisions shall be replaced by a provision approximating as much as possible the original wording and intent.

-

12.8 All complaints, feedback, comments, requests for technical support and other communications in relation to the Platform shall be directed to <ask@matters.town>.

+

12.8 All complaints, feedback, comments, requests for technical support and other communications in relation to the Platform shall be directed to <hi@matters.town>.

`, diff --git a/src/views/User/UserProfile/AsideUserProfile/index.tsx b/src/views/User/UserProfile/AsideUserProfile/index.tsx index ba655b2f59..3c3bc349d0 100644 --- a/src/views/User/UserProfile/AsideUserProfile/index.tsx +++ b/src/views/User/UserProfile/AsideUserProfile/index.tsx @@ -28,7 +28,9 @@ import { BadgeGrandDialog } from '../BadgeGrandDialog' import { BadgeNomadDialog } from '../BadgeNomadDialog' import { ArchitectBadge, + CarbonBasedBadge, CivicLikerBadge, + CommunityWatchBadge, GoldenMotorBadge, GrandBadge, NomadBadge, @@ -116,6 +118,10 @@ export const AsideUserProfile = () => { const hasSeedBadge = badges.some((b) => b.type === 'seed') const hasArchitectBadge = badges.some((b) => b.type === 'architect') const hasGoldenMotorBadge = badges.some((b) => b.type === 'golden_motor') + const hasCommunityWatchBadge = badges.some( + (b) => b.type === 'community_watch' + ) + const hasCarbonBasedBadge = badges.some((b) => b.type === 'carbon_based') const hasTraveloggersBadge = !!user.info.cryptoWallet?.hasNFTs const nomadBadgeType = badges.filter((b) => ['nomad1', 'nomad2', 'nomad3', 'nomad4'].includes(b.type) @@ -252,6 +258,8 @@ export const AsideUserProfile = () => { hasSeedBadge || hasGoldenMotorBadge || hasArchitectBadge || + hasCommunityWatchBadge || + hasCarbonBasedBadge || hasGrandBadge || isCivicLiker || user?.info.ethAddress) && ( @@ -278,6 +286,8 @@ export const AsideUserProfile = () => { {hasSeedBadge && } {hasGoldenMotorBadge && } {hasArchitectBadge && } + {hasCommunityWatchBadge && } + {hasCarbonBasedBadge && } {isCivicLiker && } {user?.info.ethAddress && ( diff --git a/src/views/User/UserProfile/Badges/index.tsx b/src/views/User/UserProfile/Badges/index.tsx index e674f8ce60..1f2e6b6138 100644 --- a/src/views/User/UserProfile/Badges/index.tsx +++ b/src/views/User/UserProfile/Badges/index.tsx @@ -1,6 +1,8 @@ import classNames from 'classnames' import { FormattedMessage } from 'react-intl' +import IconCarbonBasedBadge from '@/public/static/icons/24px/badge-carbon-based.svg' +import IconCommunityWatchBadge from '@/public/static/icons/24px/badge-community-watch.svg' import IconGrand from '@/public/static/icons/24px/badge-grand.svg' import IconNomad1Badge from '@/public/static/icons/24px/badge-nomad1-moon.svg' import IconNomad2Badge from '@/public/static/icons/24px/badge-nomad2-star.svg' @@ -87,6 +89,16 @@ export const TraveloggersBadge = withBadge({ name: 'Traveloggers', }) +export const CommunityWatchBadge = withBadge({ + icon: IconCommunityWatchBadge, + name: , +}) + +export const CarbonBasedBadge = withBadge({ + icon: IconCarbonBasedBadge, + name: , +}) + export const NomadBadge = ({ isInDialog, hasTooltip, @@ -248,6 +260,8 @@ export interface BadgesOptions { hasSeedBadge?: boolean hasGoldenMotorBadge?: boolean hasArchitectBadge?: boolean + hasCommunityWatchBadge?: boolean + hasCarbonBasedBadge?: boolean isCivicLiker?: boolean } @@ -262,6 +276,8 @@ export const Badges = ({ hasSeedBadge, hasGoldenMotorBadge, hasArchitectBadge, + hasCommunityWatchBadge, + hasCarbonBasedBadge, isCivicLiker, }: BadgesOptions) => isInDialog ? ( @@ -284,12 +300,16 @@ export const Badges = ({ hasSeedBadge || hasGoldenMotorBadge || hasArchitectBadge || + hasCommunityWatchBadge || + hasCarbonBasedBadge || isCivicLiker) && (
{hasTraveloggersBadge && } {hasSeedBadge && } {hasGoldenMotorBadge && } {hasArchitectBadge && } + {hasCommunityWatchBadge && } + {hasCarbonBasedBadge && } {isCivicLiker && }
)} @@ -302,6 +322,8 @@ export const Badges = ({ {hasSeedBadge && } {hasGoldenMotorBadge && } {hasArchitectBadge && } + {hasCommunityWatchBadge && } + {hasCarbonBasedBadge && } {isCivicLiker && } ) diff --git a/src/views/User/UserProfile/BadgesDialog/index.tsx b/src/views/User/UserProfile/BadgesDialog/index.tsx index a8bfc68baa..1a2fd9567e 100644 --- a/src/views/User/UserProfile/BadgesDialog/index.tsx +++ b/src/views/User/UserProfile/BadgesDialog/index.tsx @@ -39,6 +39,8 @@ export const BaseBadgesDialog = ({ hasSeedBadge, hasGoldenMotorBadge, hasArchitectBadge, + hasCommunityWatchBadge, + hasCarbonBasedBadge, isCivicLiker, }: BadgesDialogProps) => { const { show, openDialog, closeDialog } = useDialogSwitch(true) @@ -93,6 +95,8 @@ export const BaseBadgesDialog = ({ hasSeedBadge={hasSeedBadge} hasGoldenMotorBadge={hasGoldenMotorBadge} hasArchitectBadge={hasArchitectBadge} + hasCommunityWatchBadge={hasCommunityWatchBadge} + hasCarbonBasedBadge={hasCarbonBasedBadge} isCivicLiker={isCivicLiker} gotoNomadBadge={() => setStep('nomad')} gotoGrandBadge={() => setStep('grand')} diff --git a/src/views/User/UserProfile/DropdownActions/ToggleCommunityWatch/Button.tsx b/src/views/User/UserProfile/DropdownActions/ToggleCommunityWatch/Button.tsx new file mode 100644 index 0000000000..8fbc8860d0 --- /dev/null +++ b/src/views/User/UserProfile/DropdownActions/ToggleCommunityWatch/Button.tsx @@ -0,0 +1,75 @@ +import { useQuery } from '@apollo/client' +import gql from 'graphql-tag' + +import IconCommunityWatchBadge from '@/public/static/icons/24px/badge-community-watch.svg' +import { Icon, Menu, Spinner } from '~/components' +import { + UserCommunityWatchAdminQuery, + UserFeatureFlagType, +} from '~/gql/graphql' + +import { OpenToggleCommunityWatchDialogWithProps } from './Dialog' + +type ToggleCommunityWatchButtonProps = { + id: string + openDialog: (props: OpenToggleCommunityWatchDialogWithProps) => void +} + +export const fragments = { + user: gql` + fragment ToggleCommunityWatchUser on User { + id + oss { + featureFlags { + type + } + } + } + `, +} + +const USER_COMMUNITY_WATCH_ADMIN = gql` + query UserCommunityWatchAdmin($id: ID!) { + user: node(input: { id: $id }) { + ... on User { + id + ...ToggleCommunityWatchUser + } + } + } + ${fragments.user} +` + +const ToggleCommunityWatchButton = ({ + id, + openDialog, +}: ToggleCommunityWatchButtonProps) => { + const { data, loading } = useQuery( + USER_COMMUNITY_WATCH_ADMIN, + { variables: { id } } + ) + + if (loading) { + return } text="加載中…" /> + } + + if (data?.user?.__typename !== 'User') { + return null + } + + const flags = data.user.oss.featureFlags.map(({ type }) => type) + const enabled = flags.includes(UserFeatureFlagType.CommunityWatch) + + return ( + } + textColor={enabled ? 'greyDarker' : 'black'} + textActiveColor="black" + onClick={() => openDialog({ enabled, flags })} + ariaHasPopup="dialog" + /> + ) +} + +export default ToggleCommunityWatchButton diff --git a/src/views/User/UserProfile/DropdownActions/ToggleCommunityWatch/Dialog.tsx b/src/views/User/UserProfile/DropdownActions/ToggleCommunityWatch/Dialog.tsx new file mode 100644 index 0000000000..c82971664c --- /dev/null +++ b/src/views/User/UserProfile/DropdownActions/ToggleCommunityWatch/Dialog.tsx @@ -0,0 +1,124 @@ +import gql from 'graphql-tag' +import { useState } from 'react' +import { FormattedMessage } from 'react-intl' + +import { Dialog, toast, useDialogSwitch, useMutation } from '~/components' +import { + ToggleCommunityWatchMutation, + UserFeatureFlagType, +} from '~/gql/graphql' + +const TOGGLE_COMMUNITY_WATCH = gql` + mutation ToggleCommunityWatch($id: ID!, $flags: [UserFeatureFlagType!]!) { + putUserFeatureFlags(input: { ids: [$id], flags: $flags }) { + id + oss { + featureFlags { + type + } + } + info { + badges { + type + } + } + } + } +` + +export type OpenToggleCommunityWatchDialogWithProps = { + enabled: boolean + flags: UserFeatureFlagType[] +} + +export interface ToggleCommunityWatchDialogProps { + id: string + userName: string + children: ({ + openDialog, + }: { + openDialog: (props: OpenToggleCommunityWatchDialogWithProps) => void + }) => React.ReactNode +} + +const ToggleCommunityWatchDialog = ({ + id, + userName, + children, +}: ToggleCommunityWatchDialogProps) => { + const { show, openDialog, closeDialog } = useDialogSwitch(false) + const [enabled, setEnabled] = useState(false) + const [flags, setFlags] = useState([]) + const [toggleCommunityWatch, { loading }] = + useMutation(TOGGLE_COMMUNITY_WATCH) + + const openDialogWithProps = ({ + enabled, + flags, + }: OpenToggleCommunityWatchDialogWithProps) => { + setEnabled(enabled) + setFlags(flags) + openDialog() + } + + const onToggle = async () => { + const nextFlags = enabled + ? flags.filter((flag) => flag !== UserFeatureFlagType.CommunityWatch) + : Array.from(new Set([...flags, UserFeatureFlagType.CommunityWatch])) + + await toggleCommunityWatch({ + variables: { + id, + flags: nextFlags, + }, + }) + toast.info({ message: '設置成功' }) + closeDialog() + } + + return ( + <> + {children({ openDialog: openDialogWithProps })} + + + {enabled ? '取消守望相助隊' : '指定為守望相助隊'}} + /> + + + +

${userName}」的守望相助隊權限嗎?` + : `確認要指定「${userName}」為守望相助隊員嗎?`, + }} + /> + + + + } + color={enabled ? 'red' : 'green'} + onClick={onToggle} + loading={loading} + /> + } + smUpBtns={ + } + color={enabled ? 'red' : 'green'} + onClick={onToggle} + loading={loading} + /> + } + /> +

+ + ) +} + +export default ToggleCommunityWatchDialog diff --git a/src/views/User/UserProfile/DropdownActions/ToggleCommunityWatch/index.tsx b/src/views/User/UserProfile/DropdownActions/ToggleCommunityWatch/index.tsx new file mode 100644 index 0000000000..75599a3668 --- /dev/null +++ b/src/views/User/UserProfile/DropdownActions/ToggleCommunityWatch/index.tsx @@ -0,0 +1,10 @@ +import Button, { fragments } from './Button' +import Dialog from './Dialog' + +const ToggleCommunityWatch = { + fragments, + Dialog, + Button, +} + +export default ToggleCommunityWatch diff --git a/src/views/User/UserProfile/DropdownActions/index.tsx b/src/views/User/UserProfile/DropdownActions/index.tsx index a86a4a3bc1..0df441aaae 100644 --- a/src/views/User/UserProfile/DropdownActions/index.tsx +++ b/src/views/User/UserProfile/DropdownActions/index.tsx @@ -25,6 +25,10 @@ import { } from '~/gql/graphql' import type { ArchiveUserDialogProps } from './ArchiveUser/Dialog' +import type { + OpenToggleCommunityWatchDialogWithProps, + ToggleCommunityWatchDialogProps, +} from './ToggleCommunityWatch/Dialog' import type { OpenToggleFreezeUserDialogWithProps, ToggleFreezeUserDialogProps, @@ -58,6 +62,14 @@ const DynamicArchiveUserButton = dynamic(() => import('./ArchiveUser/Button'), { const DynamicArchiveUserDialog = dynamic(() => import('./ArchiveUser/Dialog'), { loading: () => , }) +const DynamicToggleCommunityWatchButton = dynamic( + () => import('./ToggleCommunityWatch/Button'), + { loading: () => } +) +const DynamicToggleCommunityWatchDialog = dynamic( + () => import('./ToggleCommunityWatch/Dialog'), + { loading: () => } +) interface DropdownActionsProps { user: DropdownActionsUserPublicFragment & @@ -77,6 +89,9 @@ interface Controls { } interface AdminProps { + openToggleCommunityWatchDialog: ( + props: OpenToggleCommunityWatchDialogWithProps + ) => void openToggleFreezeDialog: (props: OpenToggleFreezeUserDialogWithProps) => void openToggleRestrictDialog: ( props: OpenToggleRestrictUserDialogWithProps @@ -121,6 +136,7 @@ const BaseDropdownActions = ({ openShareDialog, // admin + openToggleCommunityWatchDialog, openToggleFreezeDialog, openToggleRestrictDialog, openArchiveDialog, @@ -148,6 +164,10 @@ const BaseDropdownActions = ({ {isAdminView && viewer.isAdmin && ( <> + { /** * ADMIN ONLY */ + const WithToggleCommunityWatch = withDialog< + Omit + >( + WithBlockUser, + DynamicToggleCommunityWatchDialog as React.ComponentType< + Omit & { + children: (props: { openDialog: () => void }) => React.ReactNode + } + >, + { id: user.id, userName: user.userName! }, + ({ openDialog }) => ({ openToggleCommunityWatchDialog: openDialog }) + ) const WithToggleFreeze = withDialog< Omit >( - WithBlockUser, + WithToggleCommunityWatch, DynamicToggleFreezeUserDialog as React.ComponentType< Omit & { children: (props: { openDialog: () => void }) => React.ReactNode diff --git a/src/views/User/UserProfile/FollowingDialog/Content/index.tsx b/src/views/User/UserProfile/FollowingDialog/Content/index.tsx index d0cb22c13d..808857f3f1 100644 --- a/src/views/User/UserProfile/FollowingDialog/Content/index.tsx +++ b/src/views/User/UserProfile/FollowingDialog/Content/index.tsx @@ -2,7 +2,8 @@ import { useState } from 'react' import { Dialog, Spacer } from '~/components' -import CirclesFeed from '../CirclesFeed' +// FEATURE IS SUNSETTING: circle tab is hidden +// import CirclesFeed from '../CirclesFeed' import FeedType, { FollowingFeedType } from '../FeedType' import UsersFeed from '../UsersFeed' @@ -13,7 +14,8 @@ const FollowingDialogContent = () => { - {feedType === 'circle' && } + {/* FEATURE IS SUNSETTING: circle tab is hidden */} + {/* {feedType === 'circle' && } */} {feedType === 'user' && } ) diff --git a/src/views/User/UserProfile/FollowingDialog/FeedType/index.tsx b/src/views/User/UserProfile/FollowingDialog/FeedType/index.tsx index 039fa04811..7656917c41 100644 --- a/src/views/User/UserProfile/FollowingDialog/FeedType/index.tsx +++ b/src/views/User/UserProfile/FollowingDialog/FeedType/index.tsx @@ -10,7 +10,8 @@ interface FeedTypeProps { } const FeedType = ({ type, setFeedType }: FeedTypeProps) => { - const isCircle = type === 'circle' + // FEATURE IS SUNSETTING: circle tab is hidden + // const isCircle = type === 'circle' const isUser = type === 'user' return ( @@ -20,9 +21,10 @@ const FeedType = ({ type, setFeedType }: FeedTypeProps) => { - setFeedType('circle')} selected={isCircle}> + {/* FEATURE IS SUNSETTING: circle tab is hidden */} + {/* setFeedType('circle')} selected={isCircle}> - + */}
) diff --git a/src/views/User/UserProfile/index.tsx b/src/views/User/UserProfile/index.tsx index fe2ac7fd78..a06a4fcdce 100644 --- a/src/views/User/UserProfile/index.tsx +++ b/src/views/User/UserProfile/index.tsx @@ -106,6 +106,10 @@ export const UserProfile = () => { const hasSeedBadge = badges.some((b) => b.type === 'seed') const hasArchitectBadge = badges.some((b) => b.type === 'architect') const hasGoldenMotorBadge = badges.some((b) => b.type === 'golden_motor') + const hasCommunityWatchBadge = badges.some( + (b) => b.type === 'community_watch' + ) + const hasCarbonBasedBadge = badges.some((b) => b.type === 'carbon_based') const hasTraveloggersBadge = !!user.info.cryptoWallet?.hasNFTs const nomadBadgeType = badges.filter((b) => ['nomad1', 'nomad2', 'nomad3', 'nomad4'].includes(b.type) @@ -214,6 +218,8 @@ export const UserProfile = () => { hasSeedBadge={hasSeedBadge} hasGoldenMotorBadge={hasGoldenMotorBadge} hasArchitectBadge={hasArchitectBadge} + hasCommunityWatchBadge={hasCommunityWatchBadge} + hasCarbonBasedBadge={hasCarbonBasedBadge} isCivicLiker={isCivicLiker} > {({ openDialog }) => ( @@ -230,6 +236,8 @@ export const UserProfile = () => { hasSeedBadge={hasSeedBadge} hasGoldenMotorBadge={hasGoldenMotorBadge} hasArchitectBadge={hasArchitectBadge} + hasCommunityWatchBadge={hasCommunityWatchBadge} + hasCarbonBasedBadge={hasCarbonBasedBadge} isCivicLiker={isCivicLiker} /> diff --git a/tests/authentication.spec.ts b/tests/authentication.spec.ts index 1bec6e74f2..77aec744e4 100644 --- a/tests/authentication.spec.ts +++ b/tests/authentication.spec.ts @@ -8,7 +8,10 @@ import { PASSWORDOR_CODE, REGISTER_CODE } from './helpers/enum' test.describe.configure({ mode: 'serial' }) test.describe('Authentication', () => { - test('can login in homepage dialog', async ({ page, isMobile }) => { + test('can login in homepage dialog @auth-smoke', async ({ + page, + isMobile, + }) => { await pageGoto(page, '/') // Expect homepage has "Enter" button @@ -41,7 +44,7 @@ test.describe('Authentication', () => { ).toBeVisible() }) - test('can login in login page', async ({ page }) => { + test('can login in login page @auth-smoke', async ({ page }) => { await login({ page, waitForNavigation: true }) await expect(page).toHaveURL('/') @@ -51,7 +54,9 @@ test.describe('Authentication', () => { ).toBeVisible() }) - test('can login with email and OTP', async ({ page }) => { + test('can login with email and OTP @auth-smoke @mutation', async ({ + page, + }) => { await pageGoto(page, '/login') // Login with email & password @@ -122,7 +127,7 @@ test.describe('Authentication', () => { }) authedTest( - 'can login and logout with worker-scoped fixtures', + 'can login and logout with worker-scoped fixtures @auth-smoke', async ({ alicePage: page, isMobile }) => { await pageGoto(page, '/') diff --git a/tests/commentArticle.spec.ts b/tests/commentArticle.spec.ts index 28fa19a5de..a354629db8 100644 --- a/tests/commentArticle.spec.ts +++ b/tests/commentArticle.spec.ts @@ -11,7 +11,7 @@ import { UserProfilePage, } from './helpers' -test.describe('Comment to article', () => { +test.describe('Comment to article @mutation', () => { authedTest( "Alice's article is commented by Bob, and received notification", async ({ alicePage, bobPage, isMobile }) => { diff --git a/tests/helpers/poms/home.ts b/tests/helpers/poms/home.ts index c627f070ec..9ff876fbc2 100644 --- a/tests/helpers/poms/home.ts +++ b/tests/helpers/poms/home.ts @@ -27,7 +27,7 @@ export class HomePage { } async goto() { - await pageGoto(this.page, '/newest') + await pageGoto(this.page, '/newest', 'domcontentloaded') } async shuffleSidebarUsers() { diff --git a/tests/helpers/utils.ts b/tests/helpers/utils.ts index 585c348342..a63229363d 100644 --- a/tests/helpers/utils.ts +++ b/tests/helpers/utils.ts @@ -1,7 +1,10 @@ import { Page } from '@playwright/test' -export const pageGoto = async (page: Page, path: string) => - await page.goto(path, { waitUntil: 'networkidle' }) +export const pageGoto = async ( + page: Page, + path: string, + waitUntil: 'load' | 'domcontentloaded' | 'networkidle' | 'commit' = 'networkidle' +) => await page.goto(path, { waitUntil }) export const sleep = async (ms: number) => { return new Promise((resolve) => { diff --git a/tests/homepage.spec.ts b/tests/homepage.spec.ts index b3a676d547..bb42b2f660 100644 --- a/tests/homepage.spec.ts +++ b/tests/homepage.spec.ts @@ -5,7 +5,7 @@ import { stripSpaces } from '~/common/utils/text' import { HomePage } from './helpers' test.describe('Homepage', () => { - test('has article feed in newest feed', async ({ page }) => { + test('has article feed in newest feed @smoke', async ({ page }) => { const home = new HomePage(page) await home.goto() @@ -18,7 +18,7 @@ test.describe('Homepage', () => { await expect(home.feedArticles.first()).toBeVisible() }) - test('can switch to featured feed', async ({ page }) => { + test('can switch to featured feed @smoke', async ({ page }) => { const home = new HomePage(page) await home.goto() @@ -32,7 +32,7 @@ test.describe('Homepage', () => { expect(await home.tabFeatured.getAttribute('aria-selected')).toBe('true') }) - test('sidebar users can be shuffled', async ({ page }) => { + test('sidebar users can be shuffled @regression', async ({ page }) => { const home = new HomePage(page) await home.goto() diff --git a/tests/mutateArticle.spec.ts b/tests/mutateArticle.spec.ts index 997dd5121e..b60e129abd 100644 --- a/tests/mutateArticle.spec.ts +++ b/tests/mutateArticle.spec.ts @@ -14,7 +14,7 @@ import { waitForAPIResponse, } from './helpers' -test.describe('Mutate article', () => { +test.describe('Mutate article @mutation', () => { authedTest( "Alice's article is appreciation by Bob, and received notification", async ({ alicePage, bobPage, isMobile }) => { diff --git a/tests/mutateUser.spec.ts b/tests/mutateUser.spec.ts index 54276fe71a..44d14108cd 100644 --- a/tests/mutateUser.spec.ts +++ b/tests/mutateUser.spec.ts @@ -46,7 +46,7 @@ const unfollow = async (page: Page) => { test.describe.configure({ mode: 'serial' }) -test.describe('User Mutation', () => { +test.describe('User Mutation @mutation', () => { authedTest( 'Bob can follow and unfollow Alice', async ({ alicePage, bobPage, isMobile }) => { diff --git a/tests/supportArticle.spec.ts b/tests/supportArticle.spec.ts index d2c2a3b7e2..f8f314bc69 100644 --- a/tests/supportArticle.spec.ts +++ b/tests/supportArticle.spec.ts @@ -13,7 +13,7 @@ import { } from './helpers' import { users } from './helpers/auth' -test.describe('Support article', () => { +test.describe('Support article @payment @mutation', () => { authedTest( "Alice's article is supported with HKD by Bob, and received notification", async ({ alicePage, bobPage, isMobile }) => { diff --git a/tests/switchBetweenUsers.spec.ts b/tests/switchBetweenUsers.spec.ts index 057cbc7865..4497946639 100644 --- a/tests/switchBetweenUsers.spec.ts +++ b/tests/switchBetweenUsers.spec.ts @@ -11,7 +11,7 @@ import { UserProfilePage, users, } from './helpers' -test.describe('Switch between multiple users', () => { +test.describe('Switch between multiple users @auth-smoke @regression @mutation', () => { authedTest('Same context', async ({ alicePage: page, isMobile }) => { test.skip(!!isMobile, 'Desktop only!') await pageGoto(page, '/newest')