diff --git a/.cursor/rules/git-analysis.mdc b/.cursor/rules/git-analysis.mdc new file mode 100644 index 00000000..a49080c1 --- /dev/null +++ b/.cursor/rules/git-analysis.mdc @@ -0,0 +1,57 @@ +--- +description: "Git information gathering for PR creation" +--- + +# Git Analysis for PR Creation + +## Required Commands + +Execute these commands in sequence: + +```bash +git status --porcelain # Check staged/unstaged files +git branch --show-current # Get current branch name +git log --oneline -10 --no-merges # Recent commit messages +git remote get-url origin # Extract owner/repo +git diff --cached --name-status # Staged changes summary +``` + +## Data Extraction + +### Repository Info + +```bash +# From: git remote get-url origin +# Extract: github.com/owner/repo.git -> owner="owner", repo="repo" +``` + +### Change Analysis + +```bash +# From: git diff --cached --name-status +# A = Added, M = Modified, D = Deleted, R = Renamed +# Example: "M src/services/auth_service.py" -> Modified auth service +``` + +### Commit Messages + +```bash +# From: git log --oneline -10 --no-merges +# Extract patterns: "feat:", "fix:", "refactor:", "docs:" +# Example: "feat: add JWT authentication" -> Feature addition +``` + +## Output Format + +Return structured data: + +```json +{ + "owner": "username", + "repo": "repository-name", + "head_branch": "feature/auth", + "changes": ["M src/services/auth_service.py", "A tests/test_auth.py"], + "commits": ["feat: add JWT authentication", "fix: handle edge cases"], + "change_type": "feature" +} +``` diff --git a/.cursor/rules/pr-creation.mdc b/.cursor/rules/pr-creation.mdc new file mode 100644 index 00000000..79de8fd4 --- /dev/null +++ b/.cursor/rules/pr-creation.mdc @@ -0,0 +1,43 @@ +--- +description: "Create GitHub PR using template and MCP tools" +--- + +# PR Creation Workflow + +When user requests PR creation, execute this workflow: + +## Core Process + +1. **Gather git information** (see [git-analysis](mdc:.cursor/rules/git-analysis.mdc)) +2. **Generate PR content** (see [pr-template-generator](mdc:.cursor/rules/pr-template-generator.mdc)) +3. **Create PR via GitHub MCP** + +## GitHub MCP Call + +Always use `mcp_GitHub_create_pull_request` with: + +```json +{ + "owner": "extracted-from-git-remote", + "repo": "extracted-from-git-remote", + "title": "feat: add user authentication system", + "head": "feature/auth", + "base": "main", + "body": "# Summary\n\n[Generated from template]...", + "draft": false +} +``` + +## Repository Requirements + +**Always include in PR body:** + +- Mongo pipeline usage confirmation +- Service logic implementation confirmation +- Reference to [PULL_REQUEST_TEMPLATE.md](mdc:.github/PULL_REQUEST_TEMPLATE.md) + +## Error Handling + +- **No changes**: "Please stage/commit changes first" +- **GitHub API failure**: Show specific error + retry guidance +- **Not in git repo**: Guide to correct directory diff --git a/.cursor/rules/pr-template-generator.mdc b/.cursor/rules/pr-template-generator.mdc new file mode 100644 index 00000000..217f5989 --- /dev/null +++ b/.cursor/rules/pr-template-generator.mdc @@ -0,0 +1,82 @@ +--- +description: "Generate PR content using template structure" +--- + +# PR Template Content Generation + +Use [PULL_REQUEST_TEMPLATE.md](mdc:.github/PULL_REQUEST_TEMPLATE.md) structure with git analysis data. + +## Content Mapping + +### Summary Section + +```markdown +# Summary + +Brief description extracted from commit messages and change analysis. +Example: "Implements JWT-based authentication system with token refresh capability." +``` + +### Type of Change + +Map git changes to template checkboxes: + +- `feat:` commits โ†’ โœจ New feature +- `fix:` commits โ†’ ๐Ÿ› Bug fix +- `refactor:` commits โ†’ โ™ป๏ธ Refactoring +- `docs:` commits โ†’ ๐Ÿ“ Documentation +- Performance-related โ†’ โšก Performance improvements +- Test files modified โ†’ ๐Ÿงช Tests + +### Changes Made + +Transform git diff output: + +```markdown +- [ ] Added JWT authentication service +- [ ] Modified user login endpoint +- [ ] Fixed token validation logic +``` + +### Motivation and Context + +Extract from commit messages: + +```markdown +**Why is this change required?** +Based on commit: "feat: add JWT authentication for better security" + +**What problem does it solve?** +Replaces session-based auth with stateless JWT tokens. +``` + +## Repository-Specific Additions + +Always append: + +```markdown +## Checklist + +- [ ] I have used mongo pipelines instead of loops where applicable (per repo guidelines) +- [ ] I have defined logic in appropriate service files +``` + +## Example Output + +```markdown +# Summary + +Implements JWT-based authentication system with token refresh capability. + +## Type of Change + +- [x] โœจ New feature (non-breaking change which adds functionality) + +## Changes Made + +- [x] Added JWT authentication service +- [x] Modified user login endpoint +- [x] Added token refresh mechanism + +[... rest of template sections filled ...] +``` diff --git a/.env.template b/.env.template index e97d7f67..1a46ad99 100644 --- a/.env.template +++ b/.env.template @@ -1,2 +1,3 @@ EXPO_PUBLIC_IS_DEV=true EXPO_PUBLIC_API_URL="https://mnt-api-880207287631.europe-west3.run.app" +EXPO_PUBLIC_DISABLE_FIREBASE=true \ No newline at end of file diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 5f84abef..724a4ae4 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,2 +1 @@ -github: [nikashelia] -custom: [] +github: [nikashelia, nikasamadalashvili] diff --git a/.github/workflows/development-local-build.yml b/.github/workflows/development-local-build.yml new file mode 100644 index 00000000..eb0818c7 --- /dev/null +++ b/.github/workflows/development-local-build.yml @@ -0,0 +1,110 @@ +name: Development Local Build + +permissions: + contents: write + +on: + workflow_dispatch: + inputs: + platform: + type: choice + description: 'Platform to build' + default: 'all' + options: + - android + - ios + - all + +env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + NODE_OPTIONS: --openssl-legacy-provider + +jobs: + build: + strategy: + matrix: + platform: [android] + include: + - platform: ios + runs-on: macos-latest + runs-on: ${{ matrix.platform == 'ios' && 'macos-latest' || 'ubuntu-latest' }} + steps: + - name: ๐Ÿ— Checkout repository + uses: actions/checkout@v4 + + - name: ๐Ÿ— Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: ๐Ÿ“ฆ Install dependencies + run: | + npm ci + npm i -g eas-cli@latest + + - name: ๐Ÿ”ง Show EAS CLI version + run: eas --version + + - name: ๐Ÿ” Prepare Firebase config files from secrets + run: | + printf "%s" "$GOOGLE_SERVICES_JSON" > google-services.json + printf "%s" "$GOOGLESERVICE_INFO_DEV_PLIST" > GoogleService-Info.plist + env: + GOOGLE_SERVICES_JSON: ${{ secrets.GOOGLE_SERVICES_JSON }} + GOOGLESERVICE_INFO_DEV_PLIST: ${{ secrets.GOOGLESERVICE_INFO_DEV_PLIST }} + + - name: โœ… Verify iOS Firebase plist exists + if: matrix.platform == 'ios' + run: | + if [ ! -s GoogleService-Info.plist ]; then + echo "GoogleService-Info.plist is missing or empty. Ensure GOOGLESERVICE_INFO_DEV_PLIST secret is set." >&2 + exit 1 + fi + + - name: ๐Ÿ“ฑ Build Android (development-local APK) + if: matrix.platform == 'android' + run: | + export NODE_OPTIONS="--openssl-legacy-provider --max_old_space_size=4096" + eas build --platform android --profile development-local --non-interactive --local --output ./app-development-local.apk + env: + NODE_ENV: development + + - name: ๐ŸŽ Build iOS (development-local dev client) + if: matrix.platform == 'ios' + run: | + export NODE_OPTIONS="--openssl-legacy-provider --max_old_space_size=4096" + eas build --platform ios --profile development-local --non-interactive --local --output ./app-development-local.ipa + env: + NODE_ENV: development + + - name: ๐Ÿท๏ธ Generate build information + id: build-info + run: | + if ! command -v jq &> /dev/null; then + sudo apt-get update && sudo apt-get install -y jq + fi + VERSION=$(npx expo config --json | jq -r '.expo.version') + BUILD_NUMBER=$(date +%Y%m%d%H%M) + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "build_number=$BUILD_NUMBER" >> $GITHUB_OUTPUT + # Generate changelog from commit messages since last tag + if git describe --tags --abbrev=0 > /dev/null 2>&1; then + LAST_TAG=$(git describe --tags --abbrev=0) + git log $LAST_TAG..HEAD --pretty=format:"- %s" > changelog.md + else + git log --pretty=format:"- %s" -n 20 > changelog.md + fi + + - name: ๐Ÿ“ Create GitHub Release + uses: softprops/action-gh-release@v1 + with: + draft: true + name: 'Development Build v${{ steps.build-info.outputs.version }}-${{ steps.build-info.outputs.build_number }}' + tag_name: 'dev-v${{ steps.build-info.outputs.version }}-${{ steps.build-info.outputs.build_number }}' + files: | + ./app-development-local.apk + ./app-development-local.ipa + body_path: changelog.md + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/ota-update.yml b/.github/workflows/ota-update.yml deleted file mode 100644 index 75322679..00000000 --- a/.github/workflows/ota-update.yml +++ /dev/null @@ -1,95 +0,0 @@ -name: EAS OTA Update - -on: - push: - branches: [dev, preview, main] - -jobs: - detect-native-change: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Detect native-impacting changes - id: detect - run: | - set -e - base_ref=$(git rev-parse HEAD~1) - echo "Comparing against $base_ref" - CHANGED=$(git diff --name-only "$base_ref" HEAD) - echo "$CHANGED" | sed 's/^/changed: /' - NATIVE_MATCHES=$(echo "$CHANGED" | grep -E '^(package.json|yarn.lock|pnpm-lock.yaml|package-lock.json|android/|ios/|app\.plugin\.(js|ts)|app\.config\.(js|ts|json)|eas\.json|plugins?/|patches/|babel\.config\.(js|ts)|metro\.config\.(js|ts))' || true) - if [ -n "$NATIVE_MATCHES" ]; then - echo "native_changed=true" >> $GITHUB_OUTPUT - echo "Native-impacting changes detected:" && echo "$NATIVE_MATCHES" - else - echo "native_changed=false" >> $GITHUB_OUTPUT - echo "No native-impacting changes detected." - fi - update: - needs: detect-native-change - if: needs.detect-native-change.outputs.native_changed == 'false' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20 - - name: Install deps - run: | - npm ci - npm i -g eas-cli@latest - - name: Determine channel - id: ch - run: | - branch="${GITHUB_REF_NAME}" - if [ "$branch" = "dev" ]; then echo "channel=development" >> $GITHUB_OUTPUT; fi - if [ "$branch" = "preview" ]; then echo "channel=preview" >> $GITHUB_OUTPUT; fi - if [ "$branch" = "main" ]; then echo "channel=production" >> $GITHUB_OUTPUT; fi - - name: EAS update - env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} - run: | - eas update --channel ${{ steps.ch.outputs.channel }} --non-interactive --message "Auto OTA: ${GITHUB_SHA::7} (${GITHUB_REF_NAME})" - - notify-skip: - needs: detect-native-change - if: needs.detect-native-change.outputs.native_changed == 'true' - runs-on: ubuntu-latest - steps: - - name: Skip notice - run: echo "Native-impacting changes detected; skipping OTA. Run a full EAS build instead." - - build-dev-android: - needs: detect-native-change - if: needs.detect-native-change.outputs.native_changed == 'true' && github.ref == 'refs/heads/dev' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20 - - name: Install deps and EAS - run: | - npm ci - npm i -g eas-cli@latest jq - - name: Build Android (development-local) - env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} - run: | - eas build --profile development-local --platform android --non-interactive --wait --json > build.json - echo "Build JSON:" && cat build.json - - name: Download APK - id: dl - run: | - url=$(jq -r '.[0].artifacts.buildUrl' build.json) - name="app-dev-${GITHUB_SHA::7}.apk" - echo "url=$url" >> $GITHUB_OUTPUT - echo "name=$name" >> $GITHUB_OUTPUT - curl -L "$url" -o "$name" - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: ${{ steps.dl.outputs.name }} - path: ${{ steps.dl.outputs.name }} diff --git a/.github/workflows/production-deploy.yml b/.github/workflows/production-deploy.yml new file mode 100644 index 00000000..355bcee5 --- /dev/null +++ b/.github/workflows/production-deploy.yml @@ -0,0 +1,131 @@ +name: Production Deploy + +permissions: + contents: read + +on: + workflow_dispatch: + inputs: + platform: + type: choice + description: 'Platform to deploy' + default: 'all' + options: + - android + - ios + - all + mode: + type: choice + description: 'Mode: build_and_submit or remote_update' + default: 'build_and_submit' + options: + - build_and_submit + - remote_update + channel: + type: string + description: 'EAS update channel (used when mode=remote_update)' + default: 'production' + +env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + EXPO_APPLE_ID: ${{ secrets.EXPO_APPLE_ID }} + EXPO_APPLE_PASSWORD: ${{ secrets.EXPO_APPLE_PASSWORD }} + EXPO_TEAM_ID: ${{ secrets.EXPO_TEAM_ID }} + GOOGLE_PLAY_SERVICE_ACCOUNT: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT }} + GOOGLE_SERVICES_JSON: ${{ secrets.GOOGLE_SERVICES_JSON }} + GOOGLESERVICE_INFO_PROD_PLIST: ${{ secrets.GOOGLESERVICE_INFO_PROD_PLIST }} + NODE_OPTIONS: --openssl-legacy-provider + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: ๐Ÿ— Checkout repository + uses: actions/checkout@v4 + + - name: ๐Ÿ— Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: ๐Ÿ“ฆ Install dependencies + run: | + npm ci + npm i -g eas-cli@latest + + - name: ๐Ÿ”ง Show EAS CLI version + run: eas --version + + - name: ๐Ÿ”„ EAS Update (remote update) + if: github.event.inputs.mode == 'remote_update' + run: | + export NODE_OPTIONS="--openssl-legacy-provider --max_old_space_size=4096" + export SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} + export EXPO_PUBLIC_SENTRY_DSN=${{ secrets.EXPO_PUBLIC_SENTRY_DSN }} + export EXPO_PUBLIC_SUPABASE_URL=${{ secrets.EXPO_PUBLIC_SUPABASE_URL }} + export EXPO_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.EXPO_PUBLIC_SUPABASE_ANON_KEY }} + export EXPO_PUBLIC_API_URL=${{ secrets.EXPO_PUBLIC_API_URL }} + eas update --channel ${{ github.event.inputs.channel }} --non-interactive --message "Remote update: ${GITHUB_SHA::7}" + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + + - name: ๐Ÿ” Prepare Firebase config files from secrets + run: | + printf "%s" "$GOOGLE_SERVICES_JSON" > google-services.json + printf "%s" "$GOOGLESERVICE_INFO_PROD_PLIST" > GoogleService-Info.plist + env: + GOOGLE_SERVICES_JSON: ${{ secrets.GOOGLE_SERVICES_JSON }} + GOOGLESERVICE_INFO_PROD_PLIST: ${{ secrets.GOOGLESERVICE_INFO_PROD_PLIST }} + + - name: โœ… Verify iOS Firebase plist exists + if: matrix.platform == 'ios' + run: | + if [ ! -s GoogleService-Info.plist ]; then + echo "GoogleService-Info.plist is missing or empty. Ensure GOOGLESERVICE_INFO_PROD_PLIST secret is set." >&2 + exit 1 + fi + + - name: ๐Ÿ“ฑ Build Android (Production AAB) + if: (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'android') && github.event.inputs.mode != 'remote_update' + run: | + export NODE_OPTIONS="--openssl-legacy-provider --max_old_space_size=4096" + export SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} + export EXPO_PUBLIC_SENTRY_DSN=${{ secrets.EXPO_PUBLIC_SENTRY_DSN }} + export EXPO_PUBLIC_SUPABASE_URL=${{ secrets.EXPO_PUBLIC_SUPABASE_URL }} + export EXPO_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.EXPO_PUBLIC_SUPABASE_ANON_KEY }} + export EXPO_PUBLIC_API_URL=${{ secrets.EXPO_PUBLIC_API_URL }} + eas build --platform android --profile production --non-interactive --local --output ./app-production.apk + env: + NODE_ENV: production + + - name: ๐Ÿš€ Submit Android to Play Store + if: (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'android') && github.event.inputs.mode != 'remote_update' + run: | + eas submit -p android --latest --non-interactive --path ./app-production.apk + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + GOOGLE_PLAY_SERVICE_ACCOUNT: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT }} + + - name: ๐Ÿ“ฑ Build iOS (Production IPA) + if: (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'ios') && github.event.inputs.mode != 'remote_update' + run: | + export NODE_OPTIONS="--openssl-legacy-provider --max_old_space_size=4096" + export SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} + export EXPO_PUBLIC_SENTRY_DSN=${{ secrets.EXPO_PUBLIC_SENTRY_DSN }} + export EXPO_PUBLIC_SUPABASE_URL=${{ secrets.EXPO_PUBLIC_SUPABASE_URL }} + export EXPO_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.EXPO_PUBLIC_SUPABASE_ANON_KEY }} + export EXPO_PUBLIC_API_URL=${{ secrets.EXPO_PUBLIC_API_URL }} + eas build --platform ios --profile production --non-interactive --local --output ./app-production.ipa + env: + NODE_ENV: production + + - name: ๐Ÿš€ Submit iOS to App Store + if: (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'ios') && github.event.inputs.mode != 'remote_update' + run: | + eas submit -p ios --latest --non-interactive --path ./app-production.ipa + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + EXPO_APPLE_ID: ${{ secrets.EXPO_APPLE_ID }} + EXPO_APPLE_PASSWORD: ${{ secrets.EXPO_APPLE_PASSWORD }} + EXPO_TEAM_ID: ${{ secrets.EXPO_TEAM_ID }} diff --git a/.github/workflows/react-native-ci.yml b/.github/workflows/react-native-ci.yml index 75238d27..c3130ac3 100644 --- a/.github/workflows/react-native-ci.yml +++ b/.github/workflows/react-native-ci.yml @@ -1,21 +1,18 @@ name: React Native CI/CD +permissions: + contents: write + on: push: branches: - dev - - preview - - main - 'release/**' - 'hotfix/**' paths-ignore: - '**.md' - 'LICENSE' - 'docs/**' - pull_request: - branches: - - main - - dev workflow_dispatch: inputs: buildType: @@ -44,6 +41,8 @@ env: EXPO_APPLE_PASSWORD: ${{ secrets.EXPO_APPLE_PASSWORD }} EXPO_TEAM_ID: ${{ secrets.EXPO_TEAM_ID }} GOOGLE_PLAY_SERVICE_ACCOUNT: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT }} + GOOGLE_SERVICES_JSON: ${{ secrets.GOOGLE_SERVICES_JSON }} + GOOGLESERVICE_INFO_PROD_PLIST: ${{ secrets.GOOGLESERVICE_INFO_PROD_PLIST }} NODE_OPTIONS: --openssl-legacy-provider jobs: @@ -61,10 +60,18 @@ jobs: - name: ๐Ÿ— Checkout repository uses: actions/checkout@v4 + - name: ๐Ÿ” Prepare Firebase config files from secrets + run: | + printf "%s" "$GOOGLE_SERVICES_JSON" > google-services.json + printf "%s" "$GOOGLESERVICE_INFO_PROD_PLIST" > GoogleService-Info.plist + env: + GOOGLE_SERVICES_JSON: ${{ secrets.GOOGLE_SERVICES_JSON }} + GOOGLESERVICE_INFO_PROD_PLIST: ${{ secrets.GOOGLESERVICE_INFO_PROD_PLIST }} + - name: ๐Ÿ— Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' cache: 'npm' - name: ๐Ÿ“ฆ Install dependencies @@ -73,9 +80,6 @@ jobs: - name: ๐Ÿงช Run TypeScript check run: npm run typecheck - # - name: ๐Ÿงน Run ESLint - # run: npm run lint - - name: ๐ŸŽจ Run Prettier check run: npm run format:check @@ -85,24 +89,31 @@ jobs: startsWith(github.ref, 'refs/heads/release/') || startsWith(github.ref, 'refs/heads/hotfix/') || github.ref == 'refs/heads/dev' || - github.ref == 'refs/heads/preview' || - github.ref == 'refs/heads/main' || + github.ref == 'refs/heads/main' )) || github.event_name == 'workflow_dispatch' strategy: matrix: platform: [android] include: - platform: ios - runs-on: macos-latest - runs-on: ${{ matrix.platform == 'ios' && 'macos-latest' || 'ubuntu-latest' }} + runs-on: macos-15 + runs-on: ${{ matrix.platform == 'ios' && 'macos-15' || 'ubuntu-latest' }} steps: - name: ๐Ÿ— Checkout repository uses: actions/checkout@v4 + - name: ๐Ÿ” Prepare Firebase config files from secrets + run: | + printf "%s" "$GOOGLE_SERVICES_JSON" > google-services.json + printf "%s" "$GOOGLESERVICE_INFO_PROD_PLIST" > GoogleService-Info.plist + env: + GOOGLE_SERVICES_JSON: ${{ secrets.GOOGLE_SERVICES_JSON }} + GOOGLESERVICE_INFO_PROD_PLIST: ${{ secrets.GOOGLESERVICE_INFO_PROD_PLIST }} + - name: ๐Ÿ— Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' cache: 'npm' - name: ๐Ÿ“ฆ Install dependencies @@ -110,25 +121,6 @@ jobs: npm ci npm i -g eas-cli@latest - - name: ๐Ÿ”– Bump version and sync EAS (preview only) - if: github.event_name == 'push' && github.ref == 'refs/heads/preview' - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} - run: | - # Bump local version (patch) without tagging to keep repo/version in sync - npm version patch --no-git-tag-version - VERSION=$(node -p "require('./package.json').version") - echo "Bumped version to $VERSION" - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add package.json package-lock.json || true - git commit -m "chore(release): $VERSION [skip ci]" || echo "No version change to commit" - git push || echo "Skip push (no changes)" - # Sync remote app version (EAS) so builds use this version - eas project:version:set "$VERSION" --non-interactive - eas project:version:get - - name: ๐Ÿ“ฑ Setup EAS build cache uses: actions/cache@v3 with: @@ -150,107 +142,38 @@ jobs: sudo apt-get update && sudo apt-get install -y jq fi - # Fix the main entry in package.json - if [ -f ./package.json ]; then - # Create a backup - cp package.json package.json.bak - # Update the package.json - jq '.main = "node_modules/expo/AppEntry.js"' package.json > package.json.tmp && mv package.json.tmp package.json - echo "Updated package.json main entry" - cat package.json | grep "main" - else - echo "package.json not found" + - name: โœ… Verify iOS Firebase plist exists + if: matrix.platform == 'ios' + run: | + if [ ! -s GoogleService-Info.plist ]; then + echo "GoogleService-Info.plist is missing or empty. Ensure GOOGLESERVICE_INFO_PROD_PLIST secret is set." >&2 exit 1 fi - - name: ๐Ÿ“ฑ Build Development APK - if: - github.event.inputs.buildType == 'all' || github.event.inputs.buildType == 'dev' || ( - github.event_name == 'push' && ( - github.ref == 'refs/heads/dev' - ) && (matrix.platform == 'android' || github.event.inputs.platform == 'all' || github.event.inputs.platform == 'android') - ) - run: | - # Build with increased memory limit - export NODE_OPTIONS="--openssl-legacy-provider --max_old_space_size=4096" - eas build --platform android --profile development --local --non-interactive --output=./app-dev.apk - env: - NODE_ENV: development - - name: ๐Ÿ“ฑ Build Preview APK - if: github.event_name == 'push' && ( - github.ref == 'refs/heads/preview' || - startsWith(github.ref, 'refs/heads/release/') || - startsWith(github.ref, 'refs/heads/hotfix/') - ) && (matrix.platform == 'android' || github.event.inputs.platform == 'all' || github.event.inputs.platform == 'android') - run: | - export NODE_OPTIONS="--openssl-legacy-provider --max_old_space_size=4096" - eas build --platform android --profile preview --local --non-interactive --output=./app-preview.apk - env: - NODE_ENV: production - - - name: ๐Ÿ“ฑ Build Production APK - if: github.event.inputs.buildType == 'all' || github.event.inputs.buildType == 'prod-apk' || (github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') && (matrix.platform == 'android' || github.event.inputs.platform == 'all' || github.event.inputs.platform == 'android')) - run: | - export NODE_OPTIONS="--openssl-legacy-provider --max_old_space_size=4096" - eas build --platform android --profile production-apk --local --non-interactive --output=./app-prod.apk - env: - NODE_ENV: production - - - name: ๐Ÿ“ฑ Build Production AAB - if: github.event.inputs.buildType == 'all' || github.event.inputs.buildType == 'prod-aab' || (github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') && (matrix.platform == 'android' || github.event.inputs.platform == 'all' || github.event.inputs.platform == 'android')) + if: github.event_name == 'push' && (matrix.platform == 'android' || github.event.inputs.platform == 'all' || github.event.inputs.platform == 'android') run: | - export NODE_OPTIONS="--openssl-legacy-provider --max_old_space_size=4096" - eas build --platform android --profile production --local --non-interactive --output=./app-prod.aab + export SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} + export EXPO_PUBLIC_SENTRY_DSN=${{ secrets.EXPO_PUBLIC_SENTRY_DSN }} + export EXPO_PUBLIC_SUPABASE_URL=${{ secrets.EXPO_PUBLIC_SUPABASE_URL }} + export EXPO_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.EXPO_PUBLIC_SUPABASE_ANON_KEY }} + export EXPO_PUBLIC_API_URL=${{ secrets.EXPO_PUBLIC_API_URL }} + eas build --platform android --profile preview --local --non-interactive --output ./app-preview.apk env: NODE_ENV: production - - name: ๐Ÿ“ฑ Build iOS Development - if: ((github.event.inputs.buildType == 'all' || github.event.inputs.buildType == 'ios-dev') && (matrix.platform == 'ios' || github.event.inputs.platform == 'all' || github.event.inputs.platform == 'ios')) || (github.event_name == 'push' && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/develop') && matrix.platform == 'ios') - run: | - export NODE_OPTIONS="--openssl-legacy-provider --max_old_space_size=4096" - eas build --platform ios --profile development --local --non-interactive --output=./app-ios-dev.app - env: - NODE_ENV: development - - name: ๐Ÿ“ฑ Build iOS Preview - if: github.event_name == 'push' && ( - github.ref == 'refs/heads/preview' || - startsWith(github.ref, 'refs/heads/release/') || - startsWith(github.ref, 'refs/heads/hotfix/') - ) && matrix.platform == 'ios' + if: github.event_name == 'push' && (matrix.platform == 'ios' || github.event.inputs.platform == 'all' || github.event.inputs.platform == 'ios') run: | - export NODE_OPTIONS="--openssl-legacy-provider --max_old_space_size=4096" - eas build --platform ios --profile preview --local --non-interactive --output=./app-ios-preview.ipa + export SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} + export EXPO_PUBLIC_SENTRY_DSN=${{ secrets.EXPO_PUBLIC_SENTRY_DSN }} + export EXPO_PUBLIC_SUPABASE_URL=${{ secrets.EXPO_PUBLIC_SUPABASE_URL }} + export EXPO_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.EXPO_PUBLIC_SUPABASE_ANON_KEY }} + export EXPO_PUBLIC_API_URL=${{ secrets.EXPO_PUBLIC_API_URL }} + eas build --platform ios --profile preview --local --non-interactive --output ./app-preview.ipa env: NODE_ENV: production - - name: ๐Ÿ“ฑ Build iOS Production - if: ((github.event.inputs.buildType == 'all' || github.event.inputs.buildType == 'ios-prod') && (matrix.platform == 'ios' || github.event.inputs.platform == 'all' || github.event.inputs.platform == 'ios')) || (github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') && matrix.platform == 'ios') - run: | - export NODE_OPTIONS="--openssl-legacy-provider --max_old_space_size=4096" - eas build --platform ios --profile production --local --non-interactive --output=./app-ios-prod.ipa - env: - NODE_ENV: production - - - name: ๐Ÿš€ Submit to Play Store - if: (github.event.inputs.buildType == 'all' || github.event.inputs.buildType == 'publish-stores') && (matrix.platform == 'android' || github.event.inputs.platform == 'all' || github.event.inputs.platform == 'android') - run: | - eas submit -p android --path ./app-prod.aab --non-interactive - env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} - GOOGLE_PLAY_SERVICE_ACCOUNT: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT }} - - - name: ๐Ÿš€ Submit to App Store - if: (github.event.inputs.buildType == 'all' || github.event.inputs.buildType == 'publish-stores') && (matrix.platform == 'ios' || github.event.inputs.platform == 'all' || github.event.inputs.platform == 'ios') - run: | - eas submit -p ios --path ./app-ios-prod.ipa --non-interactive - env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} - EXPO_APPLE_ID: ${{ secrets.EXPO_APPLE_ID }} - EXPO_APPLE_PASSWORD: ${{ secrets.EXPO_APPLE_PASSWORD }} - EXPO_TEAM_ID: ${{ secrets.EXPO_TEAM_ID }} - - name: ๐Ÿท๏ธ Generate build information id: build-info run: | @@ -277,25 +200,33 @@ jobs: name: 'Release v${{ steps.build-info.outputs.version }}-${{ steps.build-info.outputs.build_number }}' tag_name: 'v${{ steps.build-info.outputs.version }}-${{ steps.build-info.outputs.build_number }}' files: | - ./app-dev.apk - ./app-prod.apk - ./app-prod.aab - ./app-ios-dev.app - ./app-ios-prod.ipa + ./app-preview.apk + ./app-preview.ipa body_path: changelog.md env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: ๐Ÿ“ฆ Upload build artifacts to GitHub + - name: ๐Ÿ“ฆ Upload build artifacts to GitHub (per-platform) + if: ${{ !startsWith(github.ref, 'refs/heads/hotfix/') }} uses: actions/upload-artifact@v4 with: - name: app-builds + name: app-builds-${{ matrix.platform }} path: | - ./app-dev.apk ./app-preview.apk - ./app-prod.apk - ./app-prod.aab - ./app-ios-dev.app - ./app-ios-preview.ipa - ./app-ios-prod.ipa + ./app-preview.ipa + overwrite: true + if-no-files-found: ignore + retention-days: 7 + + merge-artifacts: + needs: build-and-release + if: ${{ github.event_name == 'push' && !startsWith(github.ref, 'refs/heads/hotfix/') }} + runs-on: ubuntu-latest + steps: + - name: Merge per-platform artifacts + uses: actions/upload-artifact/merge@v4 + with: + name: app-builds + pattern: app-builds-* retention-days: 7 + delete-merged: true diff --git a/.github/workflows/semantic-pr.yml b/.github/workflows/semantic-pr.yml index c235de2d..a975ef61 100644 --- a/.github/workflows/semantic-pr.yml +++ b/.github/workflows/semantic-pr.yml @@ -3,6 +3,8 @@ name: Semantic Pull Request on: pull_request_target: types: [opened, edited, synchronize] + branches-ignore: + - 'release/**' jobs: main: diff --git a/.gitignore b/.gitignore index 6f2c4b06..825374b0 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,5 @@ build-android-preview.sh build-android.sh build-ios-development.sh build-ios-preview.sh -build-ios.sh \ No newline at end of file +build-ios.sh + \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 00000000..0813b9ba --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -e + +# Ensure node is available in PATH +if ! command -v node &> /dev/null; then + # Try common Node.js installation locations + for node_path in \ + "/opt/homebrew/bin/node" \ + "/usr/local/bin/node" \ + "/usr/bin/node" \ + "$HOME/.nvm/versions/node/*/bin/node" + do + if [ -x "$node_path" ]; then + export PATH="$(dirname "$node_path"):$PATH" + break + fi + done + + # Final check if node is now available + if ! command -v node &> /dev/null; then + echo "Error: Node.js not found. Please ensure Node.js is installed and available in PATH" >&2 + echo "Tip: If using VS Code, make sure it inherits your shell environment" >&2 + exit 127 + fi +fi + +# Get list of staged files +FILES=$(git diff --cached --name-only --diff-filter=ACMR | sed 's| |\\ |g') +[ -z "$FILES" ] && exit 0 + +# Run prettier on staged files +if [ -f "./node_modules/.bin/prettier" ]; then + echo "$FILES" | xargs ./node_modules/.bin/prettier --ignore-unknown . --write + echo "$FILES" | xargs git add +fi + +if [ -f "./node_modules/typescript/bin/tsc" ]; then + node ./node_modules/typescript/bin/tsc --noEmit +fi diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..537dd7bc --- /dev/null +++ b/.prettierignore @@ -0,0 +1,14 @@ +build/ +ios/ +android/ +**/*.html +*.json +*.md +*.txt +*.xml +*.jsonc +*.json5 +*.jsonp +*.jsonld +google-services.json +GoogleService-Info.plist diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2241ffb3..698dba56 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,37 +1,7 @@ -# Contributing to WAL - Thank you for your interest in contributing! -## Getting started - -- Fork and clone the repository -- Install dependencies: `npm ci` or `yarn install` -- Start app: `npm start` -- Generate API client if backend spec changed: `npm run generate:api` - -## Branching - -- Create feature branches from `dev` -- Open PRs into `dev` -- `preview` is for pre-release; `main` is production - -## Checks - -- Typecheck: `npm run typecheck` -- Lint: `npm run lint` -- Prettier: `npm run format:check` -- Tests: `npm test` - -## Commit and PR guidelines - -- Use Conventional Commits in PR titles -- Link issues: `Closes #123` - -## Environment - -- API base URL is derived from stage in `app.config.js` +Branch from `dev` and see the README on how to get started with the development process. We prioritize bug fixes, security or performance issues at this stage. If you want to add feature please discuss it in appropriate channel first (e.g Discussions tab or even Telegram) -## Releases +use conventional commits in PR titles, copilot works great. -- Version comes from `app.config.js` -- Android APKs are uploaded to GitHub Releases; AAB to Play Store +make sure to run the appropriate linting and formatting. diff --git a/GoogleService-Info-Prod.plist b/GoogleService-Info-Prod.plist deleted file mode 100644 index 29367124..00000000 --- a/GoogleService-Info-Prod.plist +++ /dev/null @@ -1,30 +0,0 @@ - - - - - API_KEY - AIzaSyBEW4MaxdOJ6j19ahY3JigPL5xHKjAcLGU - GCM_SENDER_ID - 754510532845 - PLIST_VERSION - 1 - BUNDLE_ID - com.greetai.ment - PROJECT_ID - mnt-86e3d - STORAGE_BUCKET - mnt-86e3d.appspot.com - IS_ADS_ENABLED - - IS_ANALYTICS_ENABLED - - IS_APPINVITE_ENABLED - - IS_GCM_ENABLED - - IS_SIGNIN_ENABLED - - GOOGLE_APP_ID - 1:754510532845:ios:5208816ad22f4cc7d2bce4 - - \ No newline at end of file diff --git a/GoogleService-Info.plist b/GoogleService-Info.plist deleted file mode 100644 index c98825a5..00000000 --- a/GoogleService-Info.plist +++ /dev/null @@ -1,30 +0,0 @@ - - - - - API_KEY - AIzaSyBEW4MaxdOJ6j19ahY3JigPL5xHKjAcLGU - GCM_SENDER_ID - 754510532845 - PLIST_VERSION - 1 - BUNDLE_ID - com.greetai.mentdev - PROJECT_ID - mnt-86e3d - STORAGE_BUCKET - mnt-86e3d.appspot.com - IS_ADS_ENABLED - - IS_ANALYTICS_ENABLED - - IS_APPINVITE_ENABLED - - IS_GCM_ENABLED - - IS_SIGNIN_ENABLED - - GOOGLE_APP_ID - 1:754510532845:ios:9c3e68d9b83b5bc9d2bce4 - - \ No newline at end of file diff --git a/LICENSE b/LICENSE index c5214db9..cacf2417 100644 --- a/LICENSE +++ b/LICENSE @@ -1,22 +1,201 @@ -MIT License + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ -Copyright (c) 2025 WAL +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +1. Definitions. -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2025 Nika + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README.md b/README.md index f2c1bb0e..5be21a83 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,52 @@ -## Environment Variables +[![DeepWiki](https://img.shields.io/badge/DeepWiki-walofficial%2Fwal--react--native-blue.svg?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACwAAAAyCAYAAAAnWDnqAAAAAXNSR0IArs4c6QAAA05JREFUaEPtmUtyEzEQhtWTQyQLHNak2AB7ZnyXZMEjXMGeK/AIi+QuHrMnbChYY7MIh8g01fJoopFb0uhhEqqcbWTp06/uv1saEDv4O3n3dV60RfP947Mm9/SQc0ICFQgzfc4CYZoTPAswgSJCCUJUnAAoRHOAUOcATwbmVLWdGoH//PB8mnKqScAhsD0kYP3j/Yt5LPQe2KvcXmGvRHcDnpxfL2zOYJ1mFwrryWTz0advv1Ut4CJgf5uhDuDj5eUcAUoahrdY/56ebRWeraTjMt/00Sh3UDtjgHtQNHwcRGOC98BJEAEymycmYcWwOprTgcB6VZ5JK5TAJ+fXGLBm3FDAmn6oPPjR4rKCAoJCal2eAiQp2x0vxTPB3ALO2CRkwmDy5WohzBDwSEFKRwPbknEggCPB/imwrycgxX2NzoMCHhPkDwqYMr9tRcP5qNrMZHkVnOjRMWwLCcr8ohBVb1OMjxLwGCvjTikrsBOiA6fNyCrm8V1rP93iVPpwaE+gO0SsWmPiXB+jikdf6SizrT5qKasx5j8ABbHpFTx+vFXp9EnYQmLx02h1QTTrl6eDqxLnGjporxl3NL3agEvXdT0WmEost648sQOYAeJS9Q7bfUVoMGnjo4AZdUMQku50McDcMWcBPvr0SzbTAFDfvJqwLzgxwATnCgnp4wDl6Aa+Ax283gghmj+vj7feE2KBBRMW3FzOpLOADl0Isb5587h/U4gGvkt5v60Z1VLG8BhYjbzRwyQZemwAd6cCR5/XFWLYZRIMpX39AR0tjaGGiGzLVyhse5C9RKC6ai42ppWPKiBagOvaYk8lO7DajerabOZP46Lby5wKjw1HCRx7p9sVMOWGzb/vA1hwiWc6jm3MvQDTogQkiqIhJV0nBQBTU+3okKCFDy9WwferkHjtxib7t3xIUQtHxnIwtx4mpg26/HfwVNVDb4oI9RHmx5WGelRVlrtiw43zboCLaxv46AZeB3IlTkwouebTr1y2NjSpHz68WNFjHvupy3q8TFn3Hos2IAk4Ju5dCo8B3wP7VPr/FGaKiG+T+v+TQqIrOqMTL1VdWV1DdmcbO8KXBz6esmYWYKPwDL5b5FA1a0hwapHiom0r/cKaoqr+27/XcrS5UwSMbQAAAABJRU5ErkJggg==)](https://deepwiki.com/walofficial/wal-react-native) + +## WAL (Expo React Native) -This project uses Expo's environment variable system in two different contexts: +### Platform support -1. **Building the app**: Environment variables for building are specified in the `eas.json` file. +- This React Native app is tested on macOS only. Development on other OSes may work but is not supported at the moment. +- The backend server can be run easily on any platform via Docker. -Example: to build a preview build, use: eas build --profile preview +### Requirements -2. **EAS Updates**: When updating the app (not building), environment variables are pulled from the Expo EAS service. These are public environment variables configured on the EAS service. +- Docker +- Node.js (18+ recommended) +- A good Mac; 16GB RAM recommended for smooth development -Example: To deploy an update to a preview environment, use: eas update --channel preview --message "MESSAGE" --environment preview +### Backend -## Local Development +- Backend repository: [WAL Server](https://github.com/walofficial/wal-server) +- By default, the backend listens at `http://localhost:5500`. -For local backend development, it is recommended to use `pnpm start` to run the app without tunneling. This provides a direct connection to your local backend services. +### Setup (minimal) -## Push Notifications +```bash +npm i +``` -This project uses push notifications for both Android and iOS platforms. The required configuration files are: +You only need your Supabase URL and Anon Key. Create a new [Supabase project](https://supabase.com/dashboard/sign-in) to obtain them. -- `google-services.json`: Required for Android push notifications (development and production) -- `GoogleService-Info.plist`: Required for iOS push notifications (development and production) +Create a `.env.development` file in the project root: -These files should be properly configured for each environment (development and production). +```bash +EXPO_PUBLIC_SUPABASE_URL= +EXPO_PUBLIC_SUPABASE_ANON_KEY= +EXPO_PUBLIC_DISABLE_FIREBASE=true +``` -## Web Suppor +### Run -There is experimental support for web using the `npx expo start --web` flag. However, this is not ready for production as we are waiting for server-side rendering support. Currently, Expo only supports static file generation during build time, with SSR support being blocked by Expo's current limitations. +```bash +# Build for you sinmulator +npx expo run:ios or npx expo run:android -# tests +# If you need to publish expo dev server on LAN do this +npm start +``` + +Thatโ€™s it. With the backend on `http://localhost:5500` and the Supabase env set, the frontend and backend are connected. + +### Additional notes + +- The new architecture from Expo/React Native has performance issues on Android, and LiveKit does not support the new architecture yet. There are no plans to migrate at this time. +- You will need to add a test phone number in Supabase and set up phone number authentication with Twilio to log into the app. Twilioโ€™s test setup is free. diff --git a/app.config.js b/app.config.js index a81df868..5c3e5bff 100644 --- a/app.config.js +++ b/app.config.js @@ -5,6 +5,135 @@ const pkg = require('./package.json'); export const app_name_slug = 'wal'; export const app_name = IS_DEV ? 'WAL DEV' : 'WAL'; + +// Build plugin list dynamically so the app can run without Firebase files +const pluginsList = [ + 'expo-router', + [ + 'expo-share-intent', + { + iosActivationRules: { + NSExtensionActivationSupportsWebURLWithMaxCount: 1, + NSExtensionActivationSupportsWebPageWithMaxCount: 1, + NSExtensionActivationSupportsText: true, + NSExtensionActivationSupportsImageWithMaxCount: 10, + }, + androidIntentFilters: ['text/*', 'image/*'], + }, + ], + [ + 'expo-build-properties', + { + ios: { + useFrameworks: 'static', + }, + android: { + //LiveKit sdk requires min 24 + minSdkVersion: 24, + targetSdkVersion: 35, + }, + }, + ], + [ + 'expo-notifications', + { + icon: './assets/images/small-icon-android.png', + color: '#000', + }, + ], + [ + 'react-native-vision-camera', + { + cameraPermissionText: + '$(PRODUCT_NAME) needs access to your Camera to capture photos and videos or go live.', + + // optionally, if you want to record audio: + enableMicrophonePermission: true, + microphonePermissionText: + '$(PRODUCT_NAME) needs access to your Microphone to capture audio.', + }, + ], + [ + '@sentry/react-native/expo', + { + url: 'https://sentry.io/', + project: 'react-native', + organization: 'greetai-inc', + }, + ], + [ + 'expo-location', + { + locationPermissionText: + 'This app accesses your location to let you post videos or photos to nearby locations.', + }, + ], + [ + 'expo-contacts', + { + contactsPermission: + 'WAL needs access to your contacts to help you find friends on the app. Your contact information is only used for friend discovery and is never stored or shared.', + }, + ], + 'react-native-compressor', + [ + 'expo-build-properties', + { + ios: { + newArchEnabled: false, + }, + android: { + newArchEnabled: false, + }, + }, + ], + 'react-native-libsodium', + [ + 'react-native-share', + { + ios: ['fb', 'instagram', 'whatsapp', 'tg', 'twitter', 'tiktoksharesdk'], + android: [ + 'com.whatsapp', + 'org.telegram.messenger', + 'com.facebook.katana', + 'com.instagram.android', + 'com.twitter.android', + 'com.zhiliaoapp.musically', + ], + }, + ], + '@livekit/react-native-expo-plugin', + '@config-plugins/react-native-webrtc', + [ + 'expo-image-picker', + { + photosPermission: + '$(PRODUCT_NAME) needs access to your photos to set profile image.', + cameraPermission: + '$(PRODUCT_NAME) needs access to your Camera to capture photos and videos or go live on locations.', + }, + ], + [ + 'expo-splash-screen', + { + backgroundColor: '#000000', + image: './assets/images/icon.png', + dark: { + image: './assets/images/icon.png', + backgroundColor: '#000000', + }, + imageWidth: 200, + }, + ], +]; + +// Firebase config toggles: enable only if explicitly enabled +const DISABLE_FIREBASE = process.env.EXPO_PUBLIC_DISABLE_FIREBASE == 'true'; + +if (!DISABLE_FIREBASE) { + pluginsList.push('@react-native-firebase/app'); +} + export default { expo: { platforms: ['ios', 'android', 'web'], @@ -44,9 +173,9 @@ export default { }, supportsTablet: false, bundleIdentifier: IS_DEV ? 'com.greetai.mentdev' : 'com.greetai.ment', - googleServicesFile: IS_DEV + googleServicesFile: !DISABLE_FIREBASE ? './GoogleService-Info.plist' - : './GoogleService-Info-Prod.plist', + : undefined, }, assetBundlePatterns: ['**/*'], android: { @@ -58,7 +187,9 @@ export default { backgroundColor: '#ffffff', }, - googleServicesFile: './google-services.json', + googleServicesFile: !DISABLE_FIREBASE + ? './google-services.json' + : undefined, intentFilters: [ { action: 'VIEW', @@ -80,133 +211,7 @@ export default { ], permissions: ['READ_CONTACTS'], }, - plugins: [ - 'expo-router', - [ - 'expo-share-intent', - { - iosActivationRules: { - NSExtensionActivationSupportsWebURLWithMaxCount: 1, - NSExtensionActivationSupportsWebPageWithMaxCount: 1, - NSExtensionActivationSupportsText: true, - NSExtensionActivationSupportsImageWithMaxCount: 10, - }, - androidIntentFilters: ['text/*', 'image/*'], - }, - ], - [ - 'expo-build-properties', - { - ios: { - useFrameworks: 'static', - }, - android: { - //LiveKit sdk requires min 24 - minSdkVersion: 24, - targetSdkVersion: 35, - }, - }, - ], - [ - 'expo-notifications', - { - icon: './assets/images/small-icon-android.png', - color: '#000', - }, - ], - [ - 'react-native-vision-camera', - { - cameraPermissionText: - '$(PRODUCT_NAME) needs access to your Camera to capture photos and videos or go live.', - - // optionally, if you want to record audio: - enableMicrophonePermission: true, - microphonePermissionText: - '$(PRODUCT_NAME) needs access to your Microphone to capture audio.', - }, - ], - [ - '@sentry/react-native/expo', - { - url: 'https://sentry.io/', - project: 'react-native', - organization: 'greetai-inc', - }, - ], - [ - 'expo-location', - { - locationPermissionText: - 'This app accesses your location to let you post videos or photos to nearby locations.', - }, - ], - [ - 'expo-contacts', - { - contactsPermission: - 'WAL needs access to your contacts to help you find friends on the app. Your contact information is only used for friend discovery and is never stored or shared.', - }, - ], - '@react-native-firebase/app', - 'react-native-compressor', - [ - 'expo-build-properties', - { - ios: { - newArchEnabled: false, - }, - android: { - newArchEnabled: false, - }, - }, - ], - 'react-native-libsodium', - [ - 'react-native-share', - { - ios: [ - 'fb', - 'instagram', - 'whatsapp', - 'tg', - 'twitter', - 'tiktoksharesdk', - ], - android: [ - 'com.whatsapp', - 'org.telegram.messenger', - 'com.facebook.katana', - 'com.instagram.android', - 'com.twitter.android', - 'com.zhiliaoapp.musically', - ], - }, - ], - '@livekit/react-native-expo-plugin', - '@config-plugins/react-native-webrtc', - [ - 'expo-image-picker', - { - photosPermission: - '$(PRODUCT_NAME) needs access to your photos to set profile image.', - cameraPermission: - '$(PRODUCT_NAME) needs access to your Camera to capture photos and videos or go live on locations.', - }, - ], - [ - 'expo-splash-screen', - { - backgroundColor: '#000000', - image: './assets/images/icon.png', - dark: { - image: './assets/images/icon.png', - backgroundColor: '#000000', - }, - imageWidth: 200, - }, - ], - ], + plugins: pluginsList, experiments: { typedRoutes: true, }, diff --git a/app/(auth)/photos.tsx b/app/(auth)/photos.tsx index 4635e96d..96d6f624 100644 --- a/app/(auth)/photos.tsx +++ b/app/(auth)/photos.tsx @@ -22,7 +22,7 @@ export default function RegisterPhotos() { flex: 1, }} > - + {/* */} ); diff --git a/app/(auth)/register.tsx b/app/(auth)/register.tsx index 1027c2f5..9f226b37 100644 --- a/app/(auth)/register.tsx +++ b/app/(auth)/register.tsx @@ -2,10 +2,17 @@ import RegisterView from '@/components/RegisterView'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { View } from 'react-native'; import { useTheme } from '@/lib/theme'; +import { isUserRegistered, useSession } from '@/components/AuthLayer'; +import { Redirect, router } from 'expo-router'; export default function Register() { const insets = useSafeAreaInsets(); const theme = useTheme(); + const { user } = useSession(); + if (user && isUserRegistered(user)) { + return ; + } + return ( ; + } if (user && isUserRegistered(user)) { - return ; + return ; + } + + if (user && !isUserRegistered(user)) { + return ; } return ( diff --git a/app/(tabs)/(home)/_layout.tsx b/app/(tabs)/(home)/_layout.tsx index 90836026..58619e04 100644 --- a/app/(tabs)/(home)/_layout.tsx +++ b/app/(tabs)/(home)/_layout.tsx @@ -1,4 +1,4 @@ -import { Link, Stack, useLocalSearchParams } from 'expo-router'; +import { Stack } from 'expo-router'; import ProfileHeader from '@/components/ProfileHeader'; import { TaskTitle } from '@/components/CustomTitle'; import { ScrollReanimatedValueProvider } from '@/components/context/ScrollReanimatedValue'; @@ -95,7 +95,7 @@ export default function Layout() { /> null, }} diff --git a/app/(tabs)/(home)/index.tsx b/app/(tabs)/(home)/index.tsx index c2006770..275eb7f9 100644 --- a/app/(tabs)/(home)/index.tsx +++ b/app/(tabs)/(home)/index.tsx @@ -67,7 +67,7 @@ export default function TaskScrollableView() { return; } } - }, [data, isFetching, router, goLiveMutation, errorMsg]); + }, [data, isFetching, goLiveMutation, errorMsg]); return ( { if (!user) { @@ -151,6 +160,12 @@ export default function Component() { ); }; + const handleApplyApiBaseUrl = async () => { + try { + await setApiBaseUrlInConfig(apiBaseUrl); + } catch {} + }; + const handleClearCache = async () => { try { await AsyncStorage.clear(); @@ -216,6 +231,32 @@ export default function Component() { + + {isNonProduction && ( + +

+ API Base URL (dev/preview) +

+ + + +
+ )}
diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 075a6a7f..9286d3cb 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -1,26 +1,17 @@ -import { Redirect, router, Tabs, usePathname, useRouter } from 'expo-router'; +import { Redirect, Tabs, usePathname, useRouter } from 'expo-router'; import React, { useEffect } from 'react'; import { Stack } from 'expo-router'; -import { - BottomSheetModal, - BottomSheetModalProvider, -} from '@gorhom/bottom-sheet'; -import { StyleSheet, View, BackHandler } from 'react-native'; -import { BlurView } from 'expo-blur'; - +import { BottomSheetModalProvider } from '@gorhom/bottom-sheet'; +import { BackHandler, StyleSheet, View } from 'react-native'; import { TabBarIcon } from '@/components/navigation/TabBarIcon'; import { useColorScheme } from '@/lib/useColorScheme'; import { isUserRegistered, useSession } from '@/components/AuthLayer'; import DbUserGetter from '@/components/DbUserGetter'; -import { useNotificationHandler } from '@/components/DbUserGetter/useNotficationHandler'; import SidebarLayout from '@/components/SidebarLayout'; -import { isAndroid, isIOS, isWeb } from '@/lib/platform'; +import { isAndroid, isWeb } from '@/lib/platform'; import LocationProvider from '@/components/LocationProvider'; -import SpacesBottomSheet from '@/components/SpacesBottomSheet'; -import { Lightbox } from '@/components/Lightbox/Lightbox'; import useFeeds from '@/hooks/useFeeds'; import { useLightboxControls } from '@/lib/lightbox/lightbox'; -import { Georgia } from '@/lib/icons/Georgia'; import { useShareIntentContext } from 'expo-share-intent'; import ErrorMessageCard from '@/components/ErrorMessageCard'; import FullScreenLoader from '@/components/FullScreenLoader'; @@ -31,10 +22,76 @@ import { setAndroidNavigationBar } from '@/lib/android-navigation-bar'; import { Provider as HeaderTransformProvider } from '@/lib/context/header-transform'; import { Provider as ReactionsOverlayProvider } from '@/lib/reactionsOverlay/reactionsOverlay'; import { ReactionsOverlay } from '@/components/ReactionsOverlay/ReactionsOverlay'; -import { PortalHost } from '@/components/primitives/portal'; -import { useAtom, useSetAtom } from 'jotai'; +import { useAtomValue, useSetAtom } from 'jotai'; import { factCheckBottomSheetState } from '@/lib/atoms/news'; import { locationUserListSheetState } from '@/lib/atoms/location'; +import { isUserLiveState } from '@/components/CameraPage/atom'; +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withRepeat, + withSequence, + withTiming, +} from 'react-native-reanimated'; + +function LivePulseIcon({ children }: { children: React.ReactNode }) { + const scale = useSharedValue(1); + const opacity = useSharedValue(0.6); + + React.useEffect(() => { + scale.value = withRepeat( + withSequence( + withTiming(1.15, { duration: 900, easing: Easing.inOut(Easing.quad) }), + withTiming(1.0, { duration: 900, easing: Easing.inOut(Easing.quad) }), + ), + -1, + true, + ); + opacity.value = withRepeat( + withSequence( + withTiming(0.2, { duration: 900 }), + withTiming(0.6, { duration: 900 }), + ), + -1, + true, + ); + }, []); + + const ringStyle = useAnimatedStyle(() => { + return { + transform: [{ scale: scale.value }], + opacity: opacity.value, + }; + }); + + return ( + + + {children} + + ); +} + +const styles = StyleSheet.create({ + iconContainer: { + width: 36, + height: 36, + alignItems: 'center', + justifyContent: 'center', + }, + pulseRing: { + position: 'absolute', + width: 28, + height: 28, + borderRadius: 14, + backgroundColor: 'rgba(255,0,0,0.25)', + }, + iconInner: { + alignItems: 'center', + justifyContent: 'center', + }, +}); export default function TabLayout() { const pathname = usePathname(); @@ -76,7 +133,7 @@ export default function TabLayout() { const router = useRouter(); const setUserLocationBottomSheet = useSetAtom(locationUserListSheetState); const setIsFactCheckBottomSheetOpen = useSetAtom(factCheckBottomSheetState); - + const isUserLive = useAtomValue(isUserLiveState); useEffect(() => { if (shareIntent && session && isAndroid) { // Check if we have images or text content to share @@ -153,28 +210,6 @@ export default function TabLayout() { } }, [pathname]); - // Navigate to news feed on sign in basically. - useEffect(() => { - if ( - user?.preferred_news_feed_id && - newsFeedId && - !userIsLoading && - !isLoading - ) { - // Only navigate if we're not already on the news feed - const isOnNewsFeed = - pathname.includes('(news)') && pathname.includes(newsFeedId); - if (!isOnNewsFeed) { - router.navigate({ - pathname: '/(tabs)/(news)/[feedId]', - params: { - feedId: user.preferred_news_feed_id, - }, - }); - } - } - }, [user?.preferred_news_feed_id, newsFeedId, userIsLoading, isLoading]); - // You can keep the splash screen open, or render a loading screen like we do here. if (isLoading) { return null; @@ -243,7 +278,7 @@ export default function TabLayout() { - ( - - ), - }} - /> - + + isUserLive ? ( + + + + ) : ( + + ), + }} + /> - {Platform.OS === 'android' && ( - - )} + diff --git a/app/index.tsx b/app/index.tsx index 2170514b..849522cc 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -24,6 +24,7 @@ export default function Index() { }, [session]); if (session && !userIsLoading && user && user.preferred_news_feed_id) { + // This fires when user is signed in the application and app was fully closed. return ; } if (isLoading || userIsLoading) { diff --git a/components/AccessView/index.tsx b/components/AccessView/index.tsx index c7983bde..ae25447a 100644 --- a/components/AccessView/index.tsx +++ b/components/AccessView/index.tsx @@ -22,24 +22,16 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { Text } from '@/components/ui/text'; import Button from '@/components/Button'; -// import { Button } from "@/components/ui/button"; import { OtpInput } from 'react-native-otp-entry'; import { useMutation, useQuery } from '@tanstack/react-query'; import { authenticatingState } from '@/lib/state/auth'; import { useAtom, useAtomValue } from 'jotai'; import { supabase } from '@/lib/supabase'; -import { colors } from '@/lib/colors'; -import { Redirect, useRouter } from 'expo-router'; -import { toast } from '@backpackapp-io/react-native-toast'; import { AndroidAutoSMSRef } from './AndroidAutoSMS'; import { LogBox } from 'react-native'; -import { BottomSheetTextInput, BottomSheetView } from '@gorhom/bottom-sheet'; +import { BottomSheetView } from '@gorhom/bottom-sheet'; import { RefObject } from 'react'; -import { - showPhoneInputState, - showCountrySelectorState, - selectedCountryState, -} from './atom'; +import { showPhoneInputState, showCountrySelectorState } from './atom'; import { FontSizes, useTheme } from '@/lib/theme'; import { BlurView } from 'expo-blur'; import CountrySelector from '@/components/CountrySelector'; @@ -239,6 +231,11 @@ const SignupForm = forwardRef(function SignupForm( }, }); + useEffect(() => { + // Reset phone input visibility after returning, but don't interfere with OTP flow + if (!isAuthenticating) setShowPhoneInput((prev) => (prev ? prev : true)); + }, [isAuthenticating]); + const handleTimerStart = useCallback((duration: number) => { // This callback is called when timer starts in TimerButton }, []); diff --git a/components/AuthLayer.tsx b/components/AuthLayer.tsx index e2c07e26..1fb53ce4 100644 --- a/components/AuthLayer.tsx +++ b/components/AuthLayer.tsx @@ -87,9 +87,6 @@ export default function AuthLayer({ children }: { children: React.ReactNode }) { // Handle user registration status useEffect(() => { if (user) { - if (!isUserRegistered(user)) { - router.navigate('/(auth)/register'); - } // Send public key when we have a user sendPublicKey({ userId: user.id }); } @@ -115,7 +112,6 @@ export default function AuthLayer({ children }: { children: React.ReactNode }) { } try { - successToast({ title: t('common.finalize_user_details') }); const newUser = await handleUserNotFound(supabaseUser.data.user); setUser(newUser.data); dismissAll(); diff --git a/components/CameraPage/CaptureButton.tsx b/components/CameraPage/CaptureButton.tsx index 8cd9e26b..2a07b61f 100644 --- a/components/CameraPage/CaptureButton.tsx +++ b/components/CameraPage/CaptureButton.tsx @@ -16,7 +16,6 @@ import Reanimated, { import type { Camera, VideoFile } from 'react-native-vision-camera'; import { CAPTURE_BUTTON_SIZE } from './Constants'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import { toast } from '@backpackapp-io/react-native-toast'; import { useHaptics } from '@/lib/haptics'; import { useToast } from '../ToastUsage'; import { t } from '@/lib/i18n'; @@ -53,6 +52,7 @@ const _CaptureButton: React.FC = ({ const recordingProgress = useSharedValue(0); const recordingTimer = useRef | null>(null); const haptic = useHaptics(); + const { dismiss } = useToast(); useEffect(() => { setRecordingTimeView(isRecording); diff --git a/components/CameraPage/LiveButton.tsx b/components/CameraPage/LiveButton.tsx index 5aa6ee59..30d922ac 100644 --- a/components/CameraPage/LiveButton.tsx +++ b/components/CameraPage/LiveButton.tsx @@ -6,7 +6,10 @@ import { ActivityIndicator, } from 'react-native'; import { useMutation } from '@tanstack/react-query'; -import { requestLivekitIngressMutation } from '@/lib/api/generated/@tanstack/react-query.gen'; +import { + requestLivekitIngressMutation, + startLiveMutation, +} from '@/lib/api/generated/@tanstack/react-query.gen'; import { useToast } from '@/components/ToastUsage'; import { t } from '@/lib/i18n'; @@ -30,7 +33,7 @@ export function LiveButton({ }: LiveButtonProps) { const { error: errorToast } = useToast(); const { mutate: requestLive, isPending } = useMutation({ - ...requestLivekitIngressMutation(), + ...startLiveMutation(), onSuccess: (data) => { onShowRoom(data); }, diff --git a/components/CameraPage/LiveStream.tsx b/components/CameraPage/LiveStream.tsx index 0dc2ce51..d5df6788 100644 --- a/components/CameraPage/LiveStream.tsx +++ b/components/CameraPage/LiveStream.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState, useCallback } from 'react'; -import { View, StyleSheet, Text } from 'react-native'; +import { View, StyleSheet, Text, Alert } from 'react-native'; import { AudioSession, LiveKitRoom, @@ -10,7 +10,6 @@ import { useRoom, useRoomContext, } from '@livekit/react-native'; -import { toast } from '@backpackapp-io/react-native-toast'; import { Track, LocalVideoTrack } from 'livekit-client'; import { RoomControls } from './RoomControls'; // @ts-ignore @@ -18,6 +17,12 @@ import { mediaDevices } from '@livekit/react-native-webrtc'; import useAuth from '@/hooks/useAuth'; import { BlurView } from 'expo-blur'; import { t } from '@/lib/i18n'; +import { useToast } from '../ToastUsage'; +import { isUserLiveState } from './atom'; +import { useAtom } from 'jotai'; +import { apiClient } from '@/lib/api/client'; +import { useMutation } from '@tanstack/react-query'; +import { stopLiveMutation } from '@/lib/api/generated/@tanstack/react-query.gen'; registerGlobals(); @@ -28,19 +33,35 @@ interface LiveStreamProps { } export function LiveStream({ token, roomName, onDisconnect }: LiveStreamProps) { - const handleDisconnect = useCallback(() => { - // Ensure cleanup happens before calling the parent's onDisconnect - if (onDisconnect) { - onDisconnect(); - } - }, [onDisconnect]); + const { error: errorToast, success: successToast } = useToast(); + const [isUserLive, setIsUserLive] = useAtom(isUserLiveState); + const stopLive = useMutation({ + ...stopLiveMutation(), + onSuccess: (data) => { + if (onDisconnect) { + setIsUserLive(false); + onDisconnect(); + } + }, + }); return ( { + setIsUserLive(true); + // successToast({ + // title: t('common.live_stream_started'), + // description: t('common.live_stream_started_description'), + // }); + }} onError={(error: Error) => { - // toast(error.message); + // errorToast({ + // title: t('common.failed_to_start_live_stream'), + // description: t('common.failed_to_start_live_stream_description'), + // }); + setIsUserLive(false); }} connect={true} options={{ @@ -48,10 +69,24 @@ export function LiveStream({ token, roomName, onDisconnect }: LiveStreamProps) { }} audio={true} video={true} - onDisconnected={handleDisconnect} + onDisconnected={() => { + setIsUserLive(false); + if (onDisconnect) { + onDisconnect(); + } + }} > - + + stopLive.mutate({ + query: { + room_name: roomName, + }, + }) + } + /> ); @@ -59,15 +94,14 @@ export function LiveStream({ token, roomName, onDisconnect }: LiveStreamProps) { interface RoomViewProps { onDisconnect?: () => void; + isDisconnecting?: boolean; } -function RoomView({ onDisconnect }: RoomViewProps) { +function RoomView({ onDisconnect, isDisconnecting }: RoomViewProps) { const { localParticipant } = useLocalParticipant(); const [isMicEnabled, setIsMicEnabled] = useState(true); const [isCameraEnabled, setIsCameraEnabled] = useState(true); const [isCameraFrontFacing, setCameraFrontFacing] = useState(true); - const room = useRoomContext(); - const { user } = useAuth(); // Get all camera tracks. const tracks = useTracks([Track.Source.Camera]); @@ -229,6 +263,7 @@ function RoomView({ onDisconnect }: RoomViewProps) { }} onDisconnectClick={handleDisconnect} onSwitchCamera={handleCameraSwitch} + isDisconnecting={isDisconnecting} /> ); diff --git a/components/CameraPage/RoomControls.tsx b/components/CameraPage/RoomControls.tsx index 2cf83ce7..7b35a0cc 100644 --- a/components/CameraPage/RoomControls.tsx +++ b/components/CameraPage/RoomControls.tsx @@ -1,5 +1,11 @@ import React from 'react'; -import { View, Pressable, Text, StyleSheet } from 'react-native'; +import { + View, + Pressable, + Text, + StyleSheet, + ActivityIndicator, +} from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import Animated, { FadeIn, @@ -20,6 +26,7 @@ interface RoomControlsProps { setCameraEnabled: (enabled: boolean) => void; onDisconnectClick?: () => void; onSwitchCamera?: () => void; + isDisconnecting?: boolean; } const AnimatedPressable = Animated.createAnimatedComponent(Pressable); @@ -31,6 +38,7 @@ export function RoomControls({ setCameraEnabled, onDisconnectClick, onSwitchCamera, + isDisconnecting, }: RoomControlsProps) { const insets = useSafeAreaInsets(); @@ -175,7 +183,11 @@ export function RoomControls({ onPress={handleDisconnect} style={[styles.disconnectButton, disconnectAnimatedStyle]} > - + {isDisconnecting ? ( + + ) : ( + + )} @@ -188,7 +200,7 @@ const styles = StyleSheet.create({ position: 'absolute', flexDirection: 'row', alignItems: 'center', - backgroundColor: 'rgba(239, 68, 68, 0.9)', + backgroundColor: '#FF0000', paddingHorizontal: 12, paddingVertical: 6, borderRadius: 20, @@ -240,7 +252,7 @@ const styles = StyleSheet.create({ width: 52, height: 52, borderRadius: 26, - backgroundColor: '#EF4444', + backgroundColor: '#FF0000', justifyContent: 'center', alignItems: 'center', shadowColor: '#000', diff --git a/components/CameraPage/atom.ts b/components/CameraPage/atom.ts index 5ff3909a..c09aa47d 100644 --- a/components/CameraPage/atom.ts +++ b/components/CameraPage/atom.ts @@ -3,3 +3,5 @@ import { atom } from 'jotai'; export const lastSavedRecordingTimeState = atom(0); export const isContactSyncSheetOpenState = atom(false); + +export const isUserLiveState = atom(false); diff --git a/components/CameraPage/index.tsx b/components/CameraPage/index.tsx index c9df53c9..6793bbfa 100644 --- a/components/CameraPage/index.tsx +++ b/components/CameraPage/index.tsx @@ -55,9 +55,9 @@ import { usePreferredCameraDevice } from '../../hooks/usePreferredCameraDevice'; import { CaptureButton } from './CaptureButton'; import { useLocalSearchParams, useNavigation, useRouter } from 'expo-router'; import { CaptureButtonPhoto } from './CaptureButtonPhoto'; -import { toast } from '@backpackapp-io/react-native-toast'; import { LiveButton } from './LiveButton'; import { t } from '@/lib/i18n'; +import { useToast } from '../ToastUsage'; const ReanimatedCamera = Reanimated.createAnimatedComponent(Camera); Reanimated.addWhitelistedNativeProps({ @@ -89,7 +89,7 @@ const CameraOverlay = Reanimated.createAnimatedComponent(View); export default function CameraPage(): React.ReactElement { const navigation = useNavigation(); const { feedId } = useLocalSearchParams(); - + const { dismiss } = useToast(); const [liveDescription, setLiveDescription] = useState(''); const shouldShowMediaTypeSwitch = true; @@ -455,8 +455,8 @@ export default function CameraPage(): React.ReactElement { livekit_token: string; room_name: string; }) => { - router.replace({ - pathname: '/(tabs)/(home)/[feedId]/livestream', + router.navigate({ + pathname: '/(tabs)/(home)/livestream', params: { feedId: feedId as string, livekit_token: livekit_token, @@ -501,7 +501,7 @@ export default function CameraPage(): React.ReactElement { { - toast.remove(); + dismiss('all'); router.navigate({ pathname: '/(tabs)/(home)/[feedId]', params: { diff --git a/components/FeedItem/FeedActions.tsx b/components/FeedItem/FeedActions.tsx index 72dfb66d..c98f5e11 100644 --- a/components/FeedItem/FeedActions.tsx +++ b/components/FeedItem/FeedActions.tsx @@ -1,15 +1,19 @@ -import React, { useEffect, useRef } from 'react'; +import React, { useEffect } from 'react'; import { View, StyleSheet, - Platform, useColorScheme, Text, - Animated, Pressable, - TouchableOpacity, } from 'react-native'; import Svg, { Circle } from 'react-native-svg'; +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withRepeat, + withTiming, +} from 'react-native-reanimated'; import CommentButton from './CommentButton'; import ShareButton from './ShareButton'; import { useTheme } from '@/lib/theme'; @@ -19,13 +23,14 @@ import FactualityBadge from '../ui/FactualityBadge'; import { getFactCheckBadgeInfo } from '@/utils/factualityUtils'; import { t } from '@/lib/i18n'; import { useToast } from '../ToastUsage'; +import useVerificationById from '@/hooks/useVerificationById'; interface FeedActionsProps { verificationId: string; sourceComponent?: React.ReactNode; hideUserRects?: boolean; showFactualityBadge?: boolean; - isOwner: boolean; + // isOwner: boolean; } // Animated loading circle component @@ -36,23 +41,21 @@ const LoadingCircle = ({ color: string; size?: number; }) => { - const rotateValue = useRef(new Animated.Value(0)).current; + const rotateValue = useSharedValue(0); useEffect(() => { - const animation = Animated.loop( - Animated.timing(rotateValue, { - toValue: 1, - duration: 1000, - useNativeDriver: false, // SVG animations need useNativeDriver: false - }), + rotateValue.value = withRepeat( + withTiming(1, { duration: 1000, easing: Easing.linear }), + -1, + false, ); - animation.start(); - return () => animation.stop(); + // no cleanup needed for reanimated repeat loop }, []); - const rotate = rotateValue.interpolate({ - inputRange: [0, 1], - outputRange: ['0deg', '360deg'], + const animatedStyle = useAnimatedStyle(() => { + return { + transform: [{ rotate: `${rotateValue.value * 360}deg` }], + }; }); const radius = size / 2 - 1; @@ -61,7 +64,7 @@ const LoadingCircle = ({ const strokeDashoffset = circumference * 0.75; // Show 25% of the circle return ( - + = ({ verificationId, sourceComponent, showFactualityBadge, - isOwner, + // isOwner, }) => { const { info } = useToast(); // Use the same hook as CommentsView to ensure consistent data - const { data: verification } = { - data: { - fact_check_data: { - factuality: 'TRUE', - }, - fact_check_status: 'PENDING', - ai_video_summary_status: 'PENDING', - metadata_status: 'PENDING', - }, - }; + const { data: verification } = useVerificationById(verificationId, true, { + refetchInterval: 5000, // Same interval as CommentsView uses + }); // Extract the data from verification object const factuality = verification?.fact_check_data?.factuality; const isFactualityLoading = verification?.fact_check_status === 'PENDING'; const isSummaryLoading = verification?.ai_video_summary_status === 'PENDING'; const metadataLoading = verification?.metadata_status === 'PENDING'; - const loaderOpacity = useRef( - new Animated.Value(isFactualityLoading ? 1 : 0), - ).current; - const badgeOpacity = useRef( - new Animated.Value(isFactualityLoading ? 0 : 1), - ).current; - const summaryLoaderOpacity = useRef( - new Animated.Value(isSummaryLoading ? 1 : 0), - ).current; - const metadataLoaderOpacity = useRef( - new Animated.Value(metadataLoading ? 1 : 0), - ).current; + const loaderOpacity = useSharedValue(isFactualityLoading ? 1 : 0); + const summaryLoaderOpacity = useSharedValue(isSummaryLoading ? 1 : 0); + const metadataLoaderOpacity = useSharedValue(metadataLoading ? 1 : 0); const router = useRouter(); const pathname = usePathname(); const { closeLightbox } = useLightboxControls(); @@ -204,54 +191,46 @@ const FeedActions: React.FC = ({ useEffect(() => { if (isFactualityLoading) { - // Show loader, hide badge - Animated.parallel([ - Animated.timing(loaderOpacity, { - toValue: 1, - duration: 300, - useNativeDriver: true, - }), - Animated.timing(badgeOpacity, { - toValue: 0, - duration: 300, - useNativeDriver: true, - }), - ]).start(); + // Show loader + loaderOpacity.value = withTiming(1, { + duration: 300, + easing: Easing.inOut(Easing.ease), + }); } else if (badgeInfo) { - // Show badge, hide loader - Animated.parallel([ - Animated.timing(loaderOpacity, { - toValue: 0, - duration: 300, - useNativeDriver: true, - }), - Animated.timing(badgeOpacity, { - toValue: 1, - duration: 300, - useNativeDriver: true, - }), - ]).start(); + // Hide loader + loaderOpacity.value = withTiming(0, { + duration: 300, + easing: Easing.inOut(Easing.ease), + }); } }, [isFactualityLoading, badgeInfo]); // Handle summary loading animation useEffect(() => { - Animated.timing(summaryLoaderOpacity, { - toValue: isSummaryLoading ? 1 : 0, + summaryLoaderOpacity.value = withTiming(isSummaryLoading ? 1 : 0, { duration: 300, - useNativeDriver: true, - }).start(); + easing: Easing.inOut(Easing.ease), + }); }, [isSummaryLoading]); // Handle metadata loading animation useEffect(() => { - Animated.timing(metadataLoaderOpacity, { - toValue: metadataLoading ? 1 : 0, + metadataLoaderOpacity.value = withTiming(metadataLoading ? 1 : 0, { duration: 300, - useNativeDriver: true, - }).start(); + easing: Easing.inOut(Easing.ease), + }); }, [metadataLoading]); + const factualityAnimatedStyle = useAnimatedStyle(() => ({ + opacity: loaderOpacity.value, + })); + const summaryAnimatedStyle = useAnimatedStyle(() => ({ + opacity: summaryLoaderOpacity.value, + })); + const metadataAnimatedStyle = useAnimatedStyle(() => ({ + opacity: metadataLoaderOpacity.value, + })); + const handleFactualityPress = () => { if (isFactualityLoading || isSummaryLoading || metadataLoading) { info({ @@ -320,25 +299,13 @@ const FeedActions: React.FC = ({ /> )} {isFactualityLoading && ( - + )} {!isFactualityLoading && isSummaryLoading && ( - + )} {!isFactualityLoading && !isSummaryLoading && metadataLoading && ( - + )} diff --git a/components/FeedItem/MediaContent.tsx b/components/FeedItem/MediaContent.tsx index 0709e5dd..eb48eb81 100644 --- a/components/FeedItem/MediaContent.tsx +++ b/components/FeedItem/MediaContent.tsx @@ -33,6 +33,7 @@ import { LinearGradient } from 'expo-linear-gradient'; import { Ionicons } from '@expo/vector-icons'; import { getFactCheckBadgeInfo } from '@/utils/factualityUtils'; import { t } from '@/lib/i18n'; +import LiveStreamViewer from '../LiveStreamViewer'; interface MediaContentProps { videoUrl?: string; @@ -55,6 +56,7 @@ interface MediaContentProps { previewData?: LinkPreviewData; hasAISummary?: boolean; factuality?: number; + liveEndedAt?: string; } function MediaContent({ @@ -70,6 +72,7 @@ function MediaContent({ mediaAlt, previewData, factuality, + liveEndedAt, }: MediaContentProps) { const router = useRouter(); @@ -84,7 +87,6 @@ function MediaContent({ height: img.aspectRatio.height, }, })); - const handleSingleTap = () => { // Only navigate if there are no images and no video (i.e., text-only content) if (images.length === 0 && !videoUrl) { @@ -248,6 +250,11 @@ function MediaContent({ aspectRatio={1} verificationId={verificationId} /> + ) : livekitRoomName && !liveEndedAt && isLive ? ( + } + /> ) : videoUrl ? ( { if (user?.id === posterId) { return; @@ -137,7 +141,7 @@ function FeedItem({ return ( ); }, [ @@ -318,7 +325,7 @@ function FeedItem({ /> )} {t('common.loading')}; if (error) return ( @@ -75,7 +71,7 @@ function LiveStreamViewer({ {t('common.error_colon')} {error.message} ); - + if (!isFocused) return null; return ( { - errorToast({ - title: t('common.live_stream_disconnected'), - description: t('common.live_stream_disconnected'), - }); - router.back(); + // errorToast({ + // title: t('common.live_stream_disconnected'), + // description: t('common.live_stream_disconnected'), + // }); + // router.back(); }} > @@ -100,9 +96,7 @@ function LiveStreamViewer({ function RoomView({ topControls }: { topControls: React.ReactNode }) { const tracks = useParticipantTracks([Track.Source.Camera], 'identity'); - const router = useRouter(); const connectionState = useConnectionState(); - const { error: errorToast } = useToast(); useEffect(() => { const startAudioSession = async () => { @@ -119,7 +113,6 @@ function RoomView({ topControls }: { topControls: React.ReactNode }) { AudioSession.stopAudioSession(); }; }, []); - if (connectionState === 'connected') { if (tracks.length === 0) { return ( @@ -128,7 +121,6 @@ function RoomView({ topControls }: { topControls: React.ReactNode }) { {t('common.live_stream_unavailable')} - router.back()} /> ); diff --git a/components/LiveStreamViewer/useLiveStreamToken.ts b/components/LiveStreamViewer/useLiveStreamToken.ts index 32768dd4..0af7bd79 100644 --- a/components/LiveStreamViewer/useLiveStreamToken.ts +++ b/components/LiveStreamViewer/useLiveStreamToken.ts @@ -8,6 +8,7 @@ function useLiveStreamToken(livekitRoomName: string) { room_name: livekitRoomName, }, }), + staleTime: 1000 * 60 * 5, }); return token; diff --git a/components/LocationFeed/RatePlace.tsx b/components/LocationFeed/RatePlace.tsx index 3f10fc71..e765ae4e 100644 --- a/components/LocationFeed/RatePlace.tsx +++ b/components/LocationFeed/RatePlace.tsx @@ -1,4 +1,3 @@ -import { toast } from '@backpackapp-io/react-native-toast'; import { ThumbsDown, X } from 'lucide-react-native'; import { Heart } from 'lucide-react-native'; import { TouchableOpacity, StyleSheet } from 'react-native'; diff --git a/components/LocationFeed/index.tsx b/components/LocationFeed/index.tsx index 55d18d03..770345e8 100644 --- a/components/LocationFeed/index.tsx +++ b/components/LocationFeed/index.tsx @@ -253,6 +253,7 @@ export default function LocationFeed({ fact_check_data={item.fact_check_data} previewData={item.preview_data} thumbnail={item.verified_media_playback?.thumbnail || ''} + liveEndedAt={item.live_ended_at} /> ); }, diff --git a/components/MakePublic/index.tsx b/components/MakePublic/index.tsx index c782fc7d..9db656e0 100644 --- a/components/MakePublic/index.tsx +++ b/components/MakePublic/index.tsx @@ -1,7 +1,6 @@ import React, { useState } from 'react'; import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; import { useMakePublicMutation } from '@/hooks/useMakePublicMutation'; -import { toast } from '@backpackapp-io/react-native-toast'; import { useToast } from '../ToastUsage'; import { t } from '@/lib/i18n'; diff --git a/components/RegisterView/index.tsx b/components/RegisterView/index.tsx index 95ad99a8..f04c4523 100644 --- a/components/RegisterView/index.tsx +++ b/components/RegisterView/index.tsx @@ -40,6 +40,7 @@ import Animated, { interpolate, } from 'react-native-reanimated'; import CustomAnimatedButton from '@/components/ui/AnimatedButton'; +import UsernameProgressBar from '@/components/ui/UsernameProgressBar'; import { useDebounce } from '@uidotdev/usehooks'; import { FACT_CHECK_FEED_ID } from '@/lib/constants'; import { updateUser } from '@/lib/api/generated'; @@ -84,7 +85,6 @@ export default function RegisterView() { gender, username, }); - router.replace(`/(tabs)/(fact-check)/${FACT_CHECK_FEED_ID}`); }, mutationFn: (values: FormValues) => updateUser({ @@ -188,6 +188,9 @@ export default function RegisterView() { // Get input border color based on validation state const getInputBorderColor = () => { + if (updateUserMutation.isPending || usernameQuery.isFetching) { + return '#737373'; + } if (!username || username.length === 0) { return isDark ? '#737373' : '#d1d5db'; // Default } @@ -205,7 +208,7 @@ export default function RegisterView() { } if (isUsernameValid && usernameQuery.data?.username !== null) { - return '#22c55e'; // Green for valid and available + return '#737373'; } if (!isUsernameValid) { return '#ef4444'; // Red for invalid username @@ -220,18 +223,6 @@ export default function RegisterView() { // Get input background color for subtle validation feedback const getInputBackgroundColor = () => { - if (isUsernameValid && usernameQuery.data?.username !== null) { - return isDark ? 'rgba(34, 197, 94, 0.1)' : 'rgba(34, 197, 94, 0.05)'; - } - - if ( - hasNonLatinChars || - errors.username || - usernameQuery.data?.username === null - ) { - return isDark ? 'rgba(239, 68, 68, 0.1)' : 'rgba(239, 68, 68, 0.05)'; - } - return isDark ? 'rgba(38, 38, 38, 0.8)' : 'rgba(249, 250, 251, 0.8)'; }; @@ -299,19 +290,12 @@ export default function RegisterView() { )} - = MAX_USERNAME_LENGTH - ? '#ef4444' - : '#9ca3af', - }, - ]} - > - {username.length}/{MAX_USERNAME_LENGTH} - + )} diff --git a/components/RemoteConfigBanner/index.tsx b/components/RemoteConfigBanner/index.tsx index 7fc7a4d5..a8b72a20 100644 --- a/components/RemoteConfigBanner/index.tsx +++ b/components/RemoteConfigBanner/index.tsx @@ -1,8 +1,6 @@ // @ts-nocheck -import '@react-native-firebase/app'; import React, { useEffect } from 'react'; -import remoteConfig from '@react-native-firebase/remote-config'; import { useAtom } from 'jotai'; import { firebaseRemoteConfigState } from '../../lib/state/storage'; import { isDev } from '@/lib/api/config'; @@ -22,6 +20,9 @@ const RemoteConfigBanner = () => { useEffect(() => { const fetchAndActivateConfig = async () => { try { + // Lazily require to avoid hard dependency when Firebase is not configured + const remoteConfig = + require('@react-native-firebase/remote-config').default; await remoteConfig().setDefaults({ mentbanner: JSON.stringify({ type: 'info', diff --git a/components/RenderMdx/index.tsx b/components/RenderMdx/index.tsx index 839e6a79..a4f5a40d 100644 --- a/components/RenderMdx/index.tsx +++ b/components/RenderMdx/index.tsx @@ -1,9 +1,8 @@ import React, { Fragment } from 'react'; -import { ScrollView } from 'react-native'; +import { ScrollView, Text } from 'react-native'; import { useMarkdown, type useMarkdownHookOptions } from 'react-native-marked'; import { useTheme } from '@/lib/theme'; import { useColorScheme } from '@/lib/useColorScheme'; - interface RenderMdxProps { content: string; } @@ -30,10 +29,37 @@ const RenderMdx: React.FC = ({ content }) => { const elements = useMarkdown(cleanContent, options); + const withTextNotSelectable = (node: React.ReactNode): React.ReactNode => { + if (!React.isValidElement(node)) { + return node; + } + + const element = node as React.ReactElement; + const { children, ...restProps } = element.props ?? {}; + + const processedChildren = React.Children.map(children, (child) => + withTextNotSelectable(child), + ); + + if (element.type === Text) { + return React.cloneElement( + element, + { ...restProps, selectable: false }, + processedChildren, + ); + } + + return React.cloneElement(element, restProps, processedChildren); + }; + return ( {elements.map((element, index) => { - return {element}; + return ( + + {withTextNotSelectable(element)} + + ); })} ); diff --git a/components/SidebarLayout.tsx b/components/SidebarLayout.tsx index 99d8b61a..3746cc1d 100644 --- a/components/SidebarLayout.tsx +++ b/components/SidebarLayout.tsx @@ -1,4 +1,3 @@ -// @ts-nocheck import React, { useState } from 'react'; import { View, @@ -6,12 +5,11 @@ import { Pressable, useWindowDimensions, StyleSheet, - TextStyle, } from 'react-native'; -import { Link, usePathname, Href } from 'expo-router'; +import { Link, usePathname } from 'expo-router'; +import type { Href } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; import { H1 } from './ui/typography'; -import { useTheme } from '@/lib/theme'; /** * This is an example SidebarLayout component for web. * You can style this however you like by adjusting the flexbox styling, @@ -22,7 +20,7 @@ import { useTheme } from '@/lib/theme'; */ interface NavItemProps { - href: Href; + href: Href; icon: keyof typeof Ionicons.glyphMap; label: string; } @@ -36,7 +34,6 @@ const NavItem = ({ const pathname = usePathname(); const [isHovered, setIsHovered] = useState(false); const isActive = pathname === href; - const theme = useTheme(); if (isMobile) { return ( @@ -110,7 +107,6 @@ export default function SidebarLayout({ }) { const { width } = useWindowDimensions(); const isMobileWidth = width < 768; // Standard tablet breakpoint - const theme = useTheme(); const navigationItems: NavItemProps[] = [ { diff --git a/components/SpacesBottomSheet/index.tsx b/components/SpacesBottomSheet/index.tsx index 1dfb5e6d..d99208dd 100644 --- a/components/SpacesBottomSheet/index.tsx +++ b/components/SpacesBottomSheet/index.tsx @@ -1,10 +1,8 @@ -// @ts-nocheck -import React, { useCallback, useEffect } from 'react'; +import React, { RefObject, useCallback } from 'react'; import { BottomSheetFooter, BottomSheetModal } from '@gorhom/bottom-sheet'; import BottomSheet, { BottomSheetBackdrop } from '@gorhom/bottom-sheet'; import SpacesSheetHeader from './SpacesSheetHeader'; import { LiveKitRoom } from '@livekit/react-native'; -import { toast } from '@backpackapp-io/react-native-toast'; import { useAtom } from 'jotai'; import { activeLivekitRoomState } from './atom'; import PresenceDialog from './Viewers'; @@ -12,7 +10,6 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useQueryClient } from '@tanstack/react-query'; import SpacesBottomControls from './SpacesBottomControls'; import { isWeb } from '@/lib/platform'; -import useSheetCloseOnNavigation from '@/hooks/sheetCloseOnNavigation'; import { getBottomSheetBackgroundStyle } from '@/lib/styles'; interface SpacesBottomSheetProps { isVisible?: boolean; @@ -29,7 +26,7 @@ const SpacesBottomSheet = React.forwardRef< const queryClient = useQueryClient(); const insets = useSafeAreaInsets(); const snapPoints = React.useMemo(() => ['74%', '90%'], []); - useSheetCloseOnNavigation(ref); + ref as RefObject; const renderBackdrop = useCallback( (props: any) => ( diff --git a/components/StatusBarRenderer.tsx b/components/StatusBarRenderer.tsx new file mode 100644 index 00000000..1fca561c --- /dev/null +++ b/components/StatusBarRenderer.tsx @@ -0,0 +1,22 @@ +import { useColorScheme } from '@/lib/useColorScheme'; +import { Platform } from 'react-native'; +import { StatusBar } from 'expo-status-bar'; +import { useLightbox } from '@/lib/lightbox/lightbox'; + +function StatusBarRenderer() { + const { colorScheme } = useColorScheme(); + const { activeLightbox } = useLightbox(); + if (activeLightbox) { + return null; + } + return ( + Platform.OS === 'android' && ( + + ) + ); +} + +export default StatusBarRenderer; diff --git a/components/TakeVideo.tsx b/components/TakeVideo.tsx index f3db85d4..4353baff 100644 --- a/components/TakeVideo.tsx +++ b/components/TakeVideo.tsx @@ -3,19 +3,19 @@ import { View, StyleSheet } from 'react-native'; import { useLocalSearchParams, useRouter } from 'expo-router'; import Ionicons from '@expo/vector-icons/Ionicons'; import { Button } from './ui/button'; -import { toast } from '@backpackapp-io/react-native-toast'; import { useTheme } from '@/lib/theme'; import { useColorScheme } from '@/lib/useColorScheme'; +import { useToast } from './ToastUsage'; export default function TakeVideo({ disabled }: { disabled: boolean }) { const router = useRouter(); const { feedId } = useLocalSearchParams(); const theme = useTheme(); const { isDarkColorScheme } = useColorScheme(); - + const { dismiss } = useToast(); const onTakeVideoClick = async () => { // Dismiss previous toasts if any - toast.dismiss(); + dismiss('all'); try { const cachedVideoPath = await AsyncStorage.getItem( diff --git a/components/UserGNContentProfile.tsx b/components/UserGNContentProfile.tsx index 6b7e0708..6a2ba4f9 100644 --- a/components/UserGNContentProfile.tsx +++ b/components/UserGNContentProfile.tsx @@ -105,6 +105,7 @@ export default memo(function UserGNContentProfile({ fact_check_data={item.fact_check_data} previewData={item.preview_data} thumbnail={item.verified_media_playback?.thumbnail || ''} + liveEndedAt={item.live_ended_at} /> ), [currentViewableItemIndex], diff --git a/components/VerificationView/CommentsView.tsx b/components/VerificationView/CommentsView.tsx index feea6d21..a1a35140 100644 --- a/components/VerificationView/CommentsView.tsx +++ b/components/VerificationView/CommentsView.tsx @@ -18,7 +18,7 @@ import CommentsList from '@/components/Comments/CommentsList'; import useAuth from '@/hooks/useAuth'; import LiveStreamViewer from '@/components/LiveStreamViewer'; import SpaceView from '@/components/FeedItem/SpaceView'; -import { LocationFeedPost, Source } from '@/lib/api/generated'; +import { FeedPost, LocationFeedPost, Source, User } from '@/lib/api/generated'; import { getVideoSrc } from '@/lib/utils'; import { useAtom } from 'jotai'; import FeedActions from '../FeedItem/FeedActions'; @@ -236,7 +236,7 @@ const PostContent = memo( }: { verification: LocationFeedPost; verificationId: string; - user: any; + user?: User; }) => { const theme = useTheme(); @@ -302,7 +302,7 @@ const PostContent = memo( @@ -310,7 +310,7 @@ const PostContent = memo( @@ -345,7 +345,7 @@ const PostContent = memo( @@ -407,11 +407,10 @@ const PostContent = memo( isLive={isLive} isVisible={true} verificationId={verificationId} - name={verification.assignee_user?.username || user.username} + name={verification.assignee_user?.username || ''} time={verification.last_modified_date} avatarUrl={ - verification.assignee_user?.photos[0]?.image_url[0] || - user.photos[0]?.image_url[0] + verification.assignee_user?.photos[0]?.image_url[0] || '' } livekitRoomName={verification.livekit_room_name || undefined} isSpace={isSpace} @@ -429,6 +428,7 @@ const PostContent = memo( verification.ai_video_summary_status === 'COMPLETED' || verification.ai_video_summary_status === 'PENDING' } + liveEndedAt={verification.live_ended_at || undefined} /> {hasPreview && !imageUrl && !realTimeImageUrl && ( @@ -486,7 +486,7 @@ const CommentsView = ({ verification: initialVerification, verificationId, }: { - verification: LocationFeedPost; + verification: FeedPost; verificationId: string; }) => { const { user } = useAuth(); @@ -519,13 +519,12 @@ const CommentsView = ({ {/* Header Section */} {!verification.title ? ( - - - + {user && ( + + + + )} ); }; diff --git a/components/VideoPlayback/TopGradient.tsx b/components/VideoPlayback/TopGradient.tsx index 417e5720..7fd97c64 100644 --- a/components/VideoPlayback/TopGradient.tsx +++ b/components/VideoPlayback/TopGradient.tsx @@ -5,7 +5,6 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; const TopGradient = React.memo( ({ topControls }: { topControls: React.ReactNode }) => { - const insets = useSafeAreaInsets(); return ( { + if (max <= 0) return 0; + return Math.max(0, Math.min(1, current / max)); + }, [current, max]); + + const progress = useSharedValue(0); + const containerOpacity = useSharedValue(0); + const labelOpacity = useSharedValue(0); + const labelTranslateY = useSharedValue(4); + + useEffect(() => { + // Fade container based on progress: 0 -> 0, 0.5 -> 0.3 (max at half), 1 -> 1 + const p = clampedProgress; + let targetOpacity = 0; + if (p <= 0) { + targetOpacity = 0; + } else if (p <= 0.5) { + // Ensure at half progress opacity is no more than 0.3 + targetOpacity = p * 0.6; // 0.5 * 0.6 = 0.3 + } else { + // Smoothly scale to 1.0 by full length + targetOpacity = 0.3 + (p - 0.5) * 1.4; // 1.0 at p = 1 + } + targetOpacity = Math.max(0, Math.min(1, targetOpacity)); + containerOpacity.value = withTiming(targetOpacity, { + duration: 220, + easing: Easing.out(Easing.cubic), + }); + + progress.value = withTiming(clampedProgress, { + duration: 220, + easing: Easing.out(Easing.cubic), + }); + + const isFull = clampedProgress >= 1; + labelOpacity.value = withTiming(isFull ? 1 : 0, { + duration: 200, + easing: Easing.out(Easing.ease), + }); + labelTranslateY.value = withTiming(isFull ? 0 : 4, { + duration: 220, + easing: Easing.out(Easing.cubic), + }); + }, [clampedProgress, current]); + + const fillStyle = useAnimatedStyle(() => ({ + width: `${progress.value * 100}%`, + })); + + const labelStyle = useAnimatedStyle(() => ({ + opacity: labelOpacity.value, + transform: [{ translateY: labelTranslateY.value }], + })); + const containerStyle = useAnimatedStyle(() => ({ + opacity: containerOpacity.value, + })); + + return ( + + + {current}/{max} + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + label: { + fontSize: 12, + fontWeight: '500', + }, + bar: { + overflow: 'hidden', + }, + fill: { + width: '0%', + }, +}); diff --git a/eas.json b/eas.json index 60e8c8b3..01d00d7d 100644 --- a/eas.json +++ b/eas.json @@ -53,6 +53,9 @@ }, "submit": { "production": { + "ios": { + "ascAppId": "6670372539" + }, "android": { "releaseStatus": "completed" } diff --git a/google-services.json b/google-services.json deleted file mode 100644 index 7f9b40b4..00000000 --- a/google-services.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "project_info": { - "project_number": "754510532845", - "project_id": "mnt-86e3d", - "storage_bucket": "mnt-86e3d.appspot.com" - }, - "client": [ - { - "client_info": { - "mobilesdk_app_id": "1:754510532845:android:a4f32c9eda51a984d2bce4", - "android_client_info": { - "package_name": "com.greetai.mnt" - } - }, - "oauth_client": [], - "api_key": [ - { - "current_key": "AIzaSyANaetJBTXCH3SzhtL5GtV2V6P-yL0YaMk" - } - ], - "services": { - "appinvite_service": { - "other_platform_oauth_client": [] - } - } - }, - { - "client_info": { - "mobilesdk_app_id": "1:754510532845:android:dab78c1a89783d86d2bce4", - "android_client_info": { - "package_name": "com.greetai.mntdev" - } - }, - "oauth_client": [], - "api_key": [ - { - "current_key": "AIzaSyANaetJBTXCH3SzhtL5GtV2V6P-yL0YaMk" - } - ], - "services": { - "appinvite_service": { - "other_platform_oauth_client": [] - } - } - } - ], - "configuration_version": "1" -} diff --git a/hooks/useLocationFeedPaginated.ts b/hooks/useLocationFeedPaginated.ts index 198f13fa..fe4d710d 100644 --- a/hooks/useLocationFeedPaginated.ts +++ b/hooks/useLocationFeedPaginated.ts @@ -131,12 +131,9 @@ export function useLocationFeedPaginated({ refetchOnReconnect: false, refetchOnWindowFocus: false, refetchIntervalInBackground: false, - refetchInterval: (data) => { - const hasLiveStream = data?.state.data?.pages?.[0]?.some( - (item) => item.is_live, - ); - return hasLiveStream ? 3000 : false; - }, + // refetchInterval: (data) => { + // return false + // }, // subscribed: isFocused, }); const items = data?.pages.flatMap((page) => page) || []; diff --git a/hooks/usePokeUser.ts b/hooks/usePokeUser.ts index 2bf9a536..a6f280ed 100644 --- a/hooks/usePokeUser.ts +++ b/hooks/usePokeUser.ts @@ -1,5 +1,4 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { toast } from '@backpackapp-io/react-native-toast'; import { pokeUserLiveActionsPokeTargetUserIdPostMutation } from '@/lib/api/generated/@tanstack/react-query.gen'; import { useToast } from '@/components/ToastUsage'; diff --git a/hooks/useSendPublicKey.ts b/hooks/useSendPublicKey.ts index d7388926..0a276300 100644 --- a/hooks/useSendPublicKey.ts +++ b/hooks/useSendPublicKey.ts @@ -2,8 +2,6 @@ import { useMutation } from '@tanstack/react-query'; import ProtocolService from '@/lib/services/ProtocolService'; import { publicKeyState } from '@/lib/state/auth'; import { useAtom } from 'jotai'; -import { toast } from '@backpackapp-io/react-native-toast'; -import { sendPublicKeyChatSendPublicKeyPostMutation } from '@/lib/api/generated/@tanstack/react-query.gen'; import { sendPublicKeyChatSendPublicKeyPost } from '@/lib/api/generated'; export default function useSendPublicKey() { @@ -24,7 +22,6 @@ export default function useSendPublicKey() { }, }); - toast.dismiss('sending-public-key'); setPublicKey(identityKeyPair.publicKey); }, }); diff --git a/hooks/useUploadVideo.ts b/hooks/useUploadVideo.ts index 84643592..194a1a9f 100644 --- a/hooks/useUploadVideo.ts +++ b/hooks/useUploadVideo.ts @@ -6,7 +6,6 @@ import { useAtom, useAtomValue } from 'jotai'; import { verificationRefetchIntervalState } from '@/lib/state/chat'; import { useRef } from 'react'; import { Alert } from 'react-native'; -import { toast } from '@backpackapp-io/react-native-toast'; import useAuth from './useAuth'; import { LocationFeedPost, @@ -23,6 +22,7 @@ import { import { formDataBodySerializer } from '@/lib/utils/form-data'; import { useToast } from '@/components/ToastUsage'; import { t } from '@/lib/i18n'; +import { dismiss } from 'expo-router/build/global-state/routing'; export const useUploadVideo = ({ feedId, @@ -48,7 +48,7 @@ export const useUploadVideo = ({ const { user } = useAuth(); const timeoutRef = useRef(null); - const { success } = useToast(); + const { success, dismiss } = useToast(); const uploadBlob = useMutation({ mutationKey: ['upload-blob', feedId, isPhoto], @@ -191,7 +191,7 @@ export const useUploadVideo = ({ } }, onError: (error) => { - toast.dismiss('upload-blob'); + dismiss('all'); if (error) { console.log('error', error); Alert.alert(isPhoto ? 'แƒคแƒแƒขแƒ แƒ•แƒ”แƒ  แƒแƒ˜แƒขแƒ•แƒ˜แƒ แƒ—แƒ' : 'แƒ•แƒ˜แƒ“แƒ”แƒ แƒ•แƒ”แƒ  แƒแƒ˜แƒขแƒ•แƒ˜แƒ แƒ—แƒ'); diff --git a/hooks/useVerificationById.ts b/hooks/useVerificationById.ts index 837d5bd3..3da8cc59 100644 --- a/hooks/useVerificationById.ts +++ b/hooks/useVerificationById.ts @@ -68,7 +68,6 @@ function useVerificationById( verification_id: verificationId, }, }), - throwOnError: true, enabled: enabled, refetchOnWindowFocus: false, refetchOnMount: false, @@ -81,7 +80,8 @@ function useVerificationById( return data.ai_video_summary_status === 'PENDING' || data.fact_check_status === 'PENDING' || - data.metadata_status === 'PENDING' + data.metadata_status === 'PENDING' || + data.is_live ? refetchInterval : false; }, diff --git a/lib/api/config.ts b/lib/api/config.ts index 495a4c59..22cb707e 100644 --- a/lib/api/config.ts +++ b/lib/api/config.ts @@ -1,17 +1,48 @@ import * as Updates from 'expo-updates'; -import { isWeb, isAndroid } from '../platform'; +import { isWeb } from '../platform'; import { client } from './generated/client.gen'; import { supabase } from '../supabase'; +import AsyncStorage from '@react-native-async-storage/async-storage'; export const isDev = process.env.EXPO_PUBLIC_IS_DEV === 'true' && Updates.channel !== 'preview' && Updates.channel !== 'production'; +export const SENTRY_DSN = process.env.EXPO_PUBLIC_SENTRY_DSN; export const API_BASE_URL = isWeb ? 'http://localhost:5500' : (process.env.EXPO_PUBLIC_API_URL as string); +const API_BASE_URL_OVERRIDE_KEY = 'API_BASE_URL_OVERRIDE'; +let currentApiBaseUrl: string = API_BASE_URL; + +export const getApiBaseUrl = (): string => currentApiBaseUrl; + +export const setApiBaseUrl = async ( + newBaseUrl?: string | null, +): Promise => { + const nextBaseUrl = + newBaseUrl && newBaseUrl.trim().length > 0 + ? newBaseUrl.trim() + : API_BASE_URL; + + currentApiBaseUrl = nextBaseUrl; + client.setConfig({ + baseURL: nextBaseUrl, + }); + + try { + if (nextBaseUrl === API_BASE_URL) { + await AsyncStorage.removeItem(API_BASE_URL_OVERRIDE_KEY); + } else { + await AsyncStorage.setItem(API_BASE_URL_OVERRIDE_KEY, nextBaseUrl); + } + } catch { + // Ignore persistence errors; runtime base URL is already applied + } +}; + // Initialize the client with base URL client.setConfig({ baseURL: API_BASE_URL, @@ -21,6 +52,21 @@ client.setConfig({ }, }); +// Load and apply stored base URL override (if any) +(async () => { + try { + const storedOverride = await AsyncStorage.getItem( + API_BASE_URL_OVERRIDE_KEY, + ); + if (storedOverride && storedOverride !== currentApiBaseUrl) { + currentApiBaseUrl = storedOverride; + client.setConfig({ baseURL: storedOverride }); + } + } catch { + // Ignore storage read errors + } +})(); + // Helper to set/remove Authorization header from Supabase session token let supabaseUserToken: string | undefined; const applySupabaseAuthHeader = (token?: string) => { diff --git a/lib/api/generated/@tanstack/react-query.gen.ts b/lib/api/generated/@tanstack/react-query.gen.ts index d5b22b3c..69513412 100644 --- a/lib/api/generated/@tanstack/react-query.gen.ts +++ b/lib/api/generated/@tanstack/react-query.gen.ts @@ -22,6 +22,7 @@ import { getLiveStreamToken, requestLivekitIngress, startLive, + stopLive, createUser, getUser, updateVerificationVisibility, @@ -137,6 +138,8 @@ import type { StartLiveData, StartLiveError, StartLiveResponse2, + StopLiveData, + StopLiveError, CreateUserData, CreateUserError, CreateUserResponse, @@ -283,6 +286,7 @@ export type QueryKey = [ Pick & { _id: string; _infinite?: boolean; + tags?: ReadonlyArray; }, ]; @@ -290,14 +294,20 @@ const createQueryKey = ( id: string, options?: TOptions, infinite?: boolean, + tags?: ReadonlyArray, ): [QueryKey[0]] => { const params: QueryKey[0] = { _id: id, - baseURL: (options?.client ?? _heyApiClient).getConfig().baseURL, + baseURL: + options?.baseURL || + (options?.client ?? _heyApiClient).getConfig().baseURL, } as QueryKey[0]; if (infinite) { params._infinite = infinite; } + if (tags) { + params.tags = tags; + } if (options?.body) { params.body = options.body; } @@ -1109,6 +1119,54 @@ export const startLiveMutation = ( return mutationOptions; }; +export const stopLiveQueryKey = (options: Options) => + createQueryKey('stopLive', options); + +/** + * Stop Live + */ +export const stopLiveOptions = (options: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await stopLive({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: stopLiveQueryKey(options), + }); +}; + +/** + * Stop Live + */ +export const stopLiveMutation = ( + options?: Partial>, +): UseMutationOptions< + unknown, + AxiosError, + Options +> => { + const mutationOptions: UseMutationOptions< + unknown, + AxiosError, + Options + > = { + mutationFn: async (localOptions) => { + const { data } = await stopLive({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + export const createUserQueryKey = (options: Options) => createQueryKey('createUser', options); diff --git a/lib/api/generated/client/client.ts b/lib/api/generated/client/client.gen.ts similarity index 54% rename from lib/api/generated/client/client.ts rename to lib/api/generated/client/client.gen.ts index 41b7494e..06ff1ec7 100644 --- a/lib/api/generated/client/client.ts +++ b/lib/api/generated/client/client.gen.ts @@ -1,21 +1,31 @@ -import type { AxiosError, RawAxiosRequestHeaders } from 'axios'; +// This file is auto-generated by @hey-api/openapi-ts + +import type { AxiosError, AxiosInstance, RawAxiosRequestHeaders } from 'axios'; import axios from 'axios'; -import type { Client, Config } from './types'; +import { createSseClient } from '../core/serverSentEvents.gen'; +import type { HttpMethod } from '../core/types.gen'; +import type { Client, Config, RequestOptions } from './types.gen'; import { buildUrl, createConfig, mergeConfigs, mergeHeaders, setAuthParams, -} from './utils'; +} from './utils.gen'; export const createClient = (config: Config = {}): Client => { let _config = mergeConfigs(createConfig(), config); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { auth, ...configWithoutAuth } = _config; - const instance = axios.create(configWithoutAuth); + let instance: AxiosInstance; + + if (_config.axios && !('Axios' in _config.axios)) { + instance = _config.axios; + } else { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { auth, ...configWithoutAuth } = _config; + instance = axios.create(configWithoutAuth); + } const getConfig = (): Config => ({ ..._config }); @@ -30,8 +40,7 @@ export const createClient = (config: Config = {}): Client => { return getConfig(); }; - // @ts-expect-error - const request: Client['request'] = async (options) => { + const beforeRequest = async (options: RequestOptions) => { const opts = { ..._config, ...options, @@ -56,6 +65,13 @@ export const createClient = (config: Config = {}): Client => { const url = buildUrl(opts); + return { opts, url }; + }; + + // @ts-expect-error + const request: Client['request'] = async (options) => { + // @ts-expect-error + const { opts, url } = await beforeRequest(options); try { // assign Axios here for consistency with fetch const _axios = opts.axios!; @@ -98,18 +114,49 @@ export const createClient = (config: Config = {}): Client => { } }; + const makeMethodFn = + (method: Uppercase) => (options: RequestOptions) => + request({ ...options, method }); + + const makeSseFn = + (method: Uppercase) => async (options: RequestOptions) => { + const { opts, url } = await beforeRequest(options); + return createSseClient({ + ...opts, + body: opts.body as BodyInit | null | undefined, + headers: opts.headers as Record, + method, + // @ts-expect-error + signal: opts.signal, + url, + }); + }; + return { buildUrl, - delete: (options) => request({ ...options, method: 'DELETE' }), - get: (options) => request({ ...options, method: 'GET' }), + connect: makeMethodFn('CONNECT'), + delete: makeMethodFn('DELETE'), + get: makeMethodFn('GET'), getConfig, - head: (options) => request({ ...options, method: 'HEAD' }), + head: makeMethodFn('HEAD'), instance, - options: (options) => request({ ...options, method: 'OPTIONS' }), - patch: (options) => request({ ...options, method: 'PATCH' }), - post: (options) => request({ ...options, method: 'POST' }), - put: (options) => request({ ...options, method: 'PUT' }), + options: makeMethodFn('OPTIONS'), + patch: makeMethodFn('PATCH'), + post: makeMethodFn('POST'), + put: makeMethodFn('PUT'), request, setConfig, + sse: { + connect: makeSseFn('CONNECT'), + delete: makeSseFn('DELETE'), + get: makeSseFn('GET'), + head: makeSseFn('HEAD'), + options: makeSseFn('OPTIONS'), + patch: makeSseFn('PATCH'), + post: makeSseFn('POST'), + put: makeSseFn('PUT'), + trace: makeSseFn('TRACE'), + }, + trace: makeMethodFn('TRACE'), } as Client; }; diff --git a/lib/api/generated/client/index.ts b/lib/api/generated/client/index.ts index 15d37422..8ddc04f4 100644 --- a/lib/api/generated/client/index.ts +++ b/lib/api/generated/client/index.ts @@ -1,12 +1,14 @@ -export type { Auth } from '../core/auth'; -export type { QuerySerializerOptions } from '../core/bodySerializer'; +// This file is auto-generated by @hey-api/openapi-ts + +export type { Auth } from '../core/auth.gen'; +export type { QuerySerializerOptions } from '../core/bodySerializer.gen'; export { formDataBodySerializer, jsonBodySerializer, urlSearchParamsBodySerializer, -} from '../core/bodySerializer'; -export { buildClientParams } from '../core/params'; -export { createClient } from './client'; +} from '../core/bodySerializer.gen'; +export { buildClientParams } from '../core/params.gen'; +export { createClient } from './client.gen'; export type { Client, ClientOptions, @@ -17,5 +19,5 @@ export type { RequestOptions, RequestResult, TDataShape, -} from './types'; -export { createConfig } from './utils'; +} from './types.gen'; +export { createConfig } from './utils.gen'; diff --git a/lib/api/generated/client/types.ts b/lib/api/generated/client/types.gen.ts similarity index 67% rename from lib/api/generated/client/types.ts rename to lib/api/generated/client/types.gen.ts index 16e2492f..21789fe0 100644 --- a/lib/api/generated/client/types.ts +++ b/lib/api/generated/client/types.gen.ts @@ -1,24 +1,34 @@ +// This file is auto-generated by @hey-api/openapi-ts + import type { AxiosError, AxiosInstance, + AxiosRequestHeaders, AxiosResponse, AxiosStatic, CreateAxiosDefaults, } from 'axios'; -import type { Auth } from '../core/auth'; -import type { Client as CoreClient, Config as CoreConfig } from '../core/types'; +import type { Auth } from '../core/auth.gen'; +import type { + ServerSentEventsOptions, + ServerSentEventsResult, +} from '../core/serverSentEvents.gen'; +import type { + Client as CoreClient, + Config as CoreConfig, +} from '../core/types.gen'; export interface Config extends Omit, CoreConfig { /** - * Axios implementation. You can use this option to provide a custom - * Axios instance. + * Axios implementation. You can use this option to provide either an + * `AxiosStatic` or an `AxiosInstance`. * * @default axios */ - axios?: AxiosStatic; + axios?: AxiosStatic | AxiosInstance; /** * Base URL for all requests made by this client. */ @@ -30,7 +40,7 @@ export interface Config * {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more} */ headers?: - | CreateAxiosDefaults['headers'] + | AxiosRequestHeaders | Record< string, | string @@ -50,11 +60,20 @@ export interface Config } export interface RequestOptions< + TData = unknown, ThrowOnError extends boolean = boolean, Url extends string = string, > extends Config<{ - throwOnError: ThrowOnError; - }> { + throwOnError: ThrowOnError; + }>, + Pick< + ServerSentEventsOptions, + | 'onSseError' + | 'onSseEvent' + | 'sseDefaultRetryDelay' + | 'sseMaxRetryAttempts' + | 'sseMaxRetryDelay' + > { /** * Any body that you want to add to your request. * @@ -70,6 +89,11 @@ export interface RequestOptions< url: Url; } +export interface ClientOptions { + baseURL?: string; + throwOnError?: boolean; +} + export type RequestResult< TData = unknown, TError = unknown, @@ -94,26 +118,29 @@ export type RequestResult< }) >; -export interface ClientOptions { - baseURL?: string; - throwOnError?: boolean; -} - type MethodFn = < TData = unknown, TError = unknown, ThrowOnError extends boolean = false, >( - options: Omit, 'method'>, + options: Omit, 'method'>, ) => RequestResult; +type SseFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, +>( + options: Omit, 'method'>, +) => Promise>; + type RequestFn = < TData = unknown, TError = unknown, ThrowOnError extends boolean = false, >( - options: Omit, 'method'> & - Pick>, 'method'>, + options: Omit, 'method'> & + Pick>, 'method'>, ) => RequestResult; type BuildUrlFn = < @@ -127,7 +154,13 @@ type BuildUrlFn = < options: Pick & Omit, 'axios'>, ) => string; -export type Client = CoreClient & { +export type Client = CoreClient< + RequestFn, + Config, + MethodFn, + BuildUrlFn, + SseFn +> & { instance: AxiosInstance; }; @@ -156,7 +189,11 @@ type OmitKeys = Pick>; export type Options< TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, -> = OmitKeys, 'body' | 'path' | 'query' | 'url'> & + TResponse = unknown, +> = OmitKeys< + RequestOptions, + 'body' | 'path' | 'query' | 'url' +> & Omit; export type OptionsLegacyParser< @@ -164,12 +201,16 @@ export type OptionsLegacyParser< ThrowOnError extends boolean = boolean, > = TData extends { body?: any } ? TData extends { headers?: any } - ? OmitKeys, 'body' | 'headers' | 'url'> & TData - : OmitKeys, 'body' | 'url'> & + ? OmitKeys< + RequestOptions, + 'body' | 'headers' | 'url' + > & + TData + : OmitKeys, 'body' | 'url'> & TData & - Pick, 'headers'> + Pick, 'headers'> : TData extends { headers?: any } - ? OmitKeys, 'headers' | 'url'> & + ? OmitKeys, 'headers' | 'url'> & TData & - Pick, 'body'> - : OmitKeys, 'url'> & TData; + Pick, 'body'> + : OmitKeys, 'url'> & TData; diff --git a/lib/api/generated/client/utils.ts b/lib/api/generated/client/utils.gen.ts similarity index 63% rename from lib/api/generated/client/utils.ts rename to lib/api/generated/client/utils.gen.ts index f0eab724..19d2e18d 100644 --- a/lib/api/generated/client/utils.ts +++ b/lib/api/generated/client/utils.gen.ts @@ -1,92 +1,19 @@ -import { getAuthToken } from '../core/auth'; -import type { - QuerySerializer, - QuerySerializerOptions, -} from '../core/bodySerializer'; -import type { ArraySeparatorStyle } from '../core/pathSerializer'; +// This file is auto-generated by @hey-api/openapi-ts + +import { getAuthToken } from '../core/auth.gen'; +import type { QuerySerializerOptions } from '../core/bodySerializer.gen'; import { serializeArrayParam, serializeObjectParam, serializePrimitiveParam, -} from '../core/pathSerializer'; -import type { Client, ClientOptions, Config, RequestOptions } from './types'; - -interface PathSerializer { - path: Record; - url: string; -} - -const PATH_PARAM_RE = /\{[^{}]+\}/g; - -const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { - let url = _url; - const matches = _url.match(PATH_PARAM_RE); - if (matches) { - for (const match of matches) { - let explode = false; - let name = match.substring(1, match.length - 1); - let style: ArraySeparatorStyle = 'simple'; - - if (name.endsWith('*')) { - explode = true; - name = name.substring(0, name.length - 1); - } - - if (name.startsWith('.')) { - name = name.substring(1); - style = 'label'; - } else if (name.startsWith(';')) { - name = name.substring(1); - style = 'matrix'; - } - - const value = path[name]; - - if (value === undefined || value === null) { - continue; - } - - if (Array.isArray(value)) { - url = url.replace( - match, - serializeArrayParam({ explode, name, style, value }), - ); - continue; - } - - if (typeof value === 'object') { - url = url.replace( - match, - serializeObjectParam({ - explode, - name, - style, - value: value as Record, - valueOnly: true, - }), - ); - continue; - } - - if (style === 'matrix') { - url = url.replace( - match, - `;${serializePrimitiveParam({ - name, - value: value as string, - })}`, - ); - continue; - } - - const replaceValue = encodeURIComponent( - style === 'label' ? `.${value as string}` : (value as string), - ); - url = url.replace(match, replaceValue); - } - } - return url; -}; +} from '../core/pathSerializer.gen'; +import { getUrl } from '../core/utils.gen'; +import type { + Client, + ClientOptions, + Config, + RequestOptions, +} from './types.gen'; export const createQuerySerializer = ({ allowReserved, @@ -138,6 +65,28 @@ export const createQuerySerializer = ({ return querySerializer; }; +const checkForExistence = ( + options: Pick & { + headers: Record; + }, + name?: string, +): boolean => { + if (!name) { + return false; + } + if (name in options.headers || options.query?.[name]) { + return true; + } + if ( + 'Cookie' in options.headers && + options.headers['Cookie'] && + typeof options.headers['Cookie'] === 'string' + ) { + return options.headers['Cookie'].includes(`${name}=`); + } + return false; +}; + export const setAuthParams = async ({ security, ...options @@ -146,6 +95,9 @@ export const setAuthParams = async ({ headers: Record; }) => { for (const auth of security) { + if (checkForExistence(options, auth.name)) { + continue; + } const token = await getAuthToken(auth, options.auth); if (!token) { @@ -175,13 +127,12 @@ export const setAuthParams = async ({ options.headers[name] = token; break; } - - return; } }; -export const buildUrl: Client['buildUrl'] = (options) => { - const url = getUrl({ +export const buildUrl: Client['buildUrl'] = (options) => + getUrl({ + baseUrl: options.baseURL as string, path: options.path, // let `paramsSerializer()` handle query params if it exists query: !options.paramsSerializer ? options.query : undefined, @@ -191,34 +142,6 @@ export const buildUrl: Client['buildUrl'] = (options) => { : createQuerySerializer(options.querySerializer), url: options.url, }); - return url; -}; - -export const getUrl = ({ - path, - query, - querySerializer, - url: _url, -}: { - path?: Record; - query?: Record; - querySerializer: QuerySerializer; - url: string; -}) => { - const pathUrl = _url.startsWith('/') ? _url : `/${_url}`; - let url = pathUrl; - if (path) { - url = defaultPathSerializer({ path, url }); - } - let search = query ? querySerializer(query) : ''; - if (search.startsWith('?')) { - search = search.substring(1); - } - if (search) { - url += `?${search}`; - } - return url; -}; export const mergeConfigs = (a: Config, b: Config): Config => { const config = { ...a, ...b }; diff --git a/lib/api/generated/core/auth.ts b/lib/api/generated/core/auth.gen.ts similarity index 93% rename from lib/api/generated/core/auth.ts rename to lib/api/generated/core/auth.gen.ts index 451c7f30..f8a73266 100644 --- a/lib/api/generated/core/auth.ts +++ b/lib/api/generated/core/auth.gen.ts @@ -1,3 +1,5 @@ +// This file is auto-generated by @hey-api/openapi-ts + export type AuthToken = string | undefined; export interface Auth { diff --git a/lib/api/generated/core/bodySerializer.ts b/lib/api/generated/core/bodySerializer.gen.ts similarity index 84% rename from lib/api/generated/core/bodySerializer.ts rename to lib/api/generated/core/bodySerializer.gen.ts index 21c23574..49cd8925 100644 --- a/lib/api/generated/core/bodySerializer.ts +++ b/lib/api/generated/core/bodySerializer.gen.ts @@ -1,8 +1,10 @@ +// This file is auto-generated by @hey-api/openapi-ts + import type { ArrayStyle, ObjectStyle, SerializerOptions, -} from './pathSerializer'; +} from './pathSerializer.gen'; export type QuerySerializer = (query: Record) => string; @@ -14,9 +16,15 @@ export interface QuerySerializerOptions { object?: SerializerOptions; } -const serializeFormDataPair = (data: FormData, key: string, value: unknown) => { +const serializeFormDataPair = ( + data: FormData, + key: string, + value: unknown, +): void => { if (typeof value === 'string' || value instanceof Blob) { data.append(key, value); + } else if (value instanceof Date) { + data.append(key, value.toISOString()); } else { data.append(key, JSON.stringify(value)); } @@ -26,7 +34,7 @@ const serializeUrlSearchParamsPair = ( data: URLSearchParams, key: string, value: unknown, -) => { +): void => { if (typeof value === 'string') { data.append(key, value); } else { @@ -37,7 +45,7 @@ const serializeUrlSearchParamsPair = ( export const formDataBodySerializer = { bodySerializer: | Array>>( body: T, - ) => { + ): FormData => { const data = new FormData(); Object.entries(body).forEach(([key, value]) => { @@ -56,7 +64,7 @@ export const formDataBodySerializer = { }; export const jsonBodySerializer = { - bodySerializer: (body: T) => + bodySerializer: (body: T): string => JSON.stringify(body, (_key, value) => typeof value === 'bigint' ? value.toString() : value, ), @@ -65,7 +73,7 @@ export const jsonBodySerializer = { export const urlSearchParamsBodySerializer = { bodySerializer: | Array>>( body: T, - ) => { + ): string => { const data = new URLSearchParams(); Object.entries(body).forEach(([key, value]) => { diff --git a/lib/api/generated/core/params.ts b/lib/api/generated/core/params.gen.ts similarity index 89% rename from lib/api/generated/core/params.ts rename to lib/api/generated/core/params.gen.ts index 7559bbb8..71c88e85 100644 --- a/lib/api/generated/core/params.ts +++ b/lib/api/generated/core/params.gen.ts @@ -1,13 +1,25 @@ +// This file is auto-generated by @hey-api/openapi-ts + type Slot = 'body' | 'headers' | 'path' | 'query'; export type Field = | { in: Exclude; + /** + * Field name. This is the name we want the user to see and use. + */ key: string; + /** + * Field mapped name. This is the name we want to use in the request. + * If omitted, we use the same value as `key`. + */ map?: string; } | { in: Extract; + /** + * Key isn't required for bodies. + */ key?: string; map?: string; }; diff --git a/lib/api/generated/core/pathSerializer.ts b/lib/api/generated/core/pathSerializer.gen.ts similarity index 98% rename from lib/api/generated/core/pathSerializer.ts rename to lib/api/generated/core/pathSerializer.gen.ts index d692cf0a..8d999310 100644 --- a/lib/api/generated/core/pathSerializer.ts +++ b/lib/api/generated/core/pathSerializer.gen.ts @@ -1,3 +1,5 @@ +// This file is auto-generated by @hey-api/openapi-ts + interface SerializeOptions extends SerializePrimitiveOptions, SerializerOptions {} diff --git a/lib/api/generated/core/serverSentEvents.gen.ts b/lib/api/generated/core/serverSentEvents.gen.ts new file mode 100644 index 00000000..01b5818f --- /dev/null +++ b/lib/api/generated/core/serverSentEvents.gen.ts @@ -0,0 +1,237 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Config } from './types.gen'; + +export type ServerSentEventsOptions = Omit< + RequestInit, + 'method' +> & + Pick & { + /** + * Callback invoked when a network or parsing error occurs during streaming. + * + * This option applies only if the endpoint returns a stream of events. + * + * @param error The error that occurred. + */ + onSseError?: (error: unknown) => void; + /** + * Callback invoked when an event is streamed from the server. + * + * This option applies only if the endpoint returns a stream of events. + * + * @param event Event streamed from the server. + * @returns Nothing (void). + */ + onSseEvent?: (event: StreamEvent) => void; + /** + * Default retry delay in milliseconds. + * + * This option applies only if the endpoint returns a stream of events. + * + * @default 3000 + */ + sseDefaultRetryDelay?: number; + /** + * Maximum number of retry attempts before giving up. + */ + sseMaxRetryAttempts?: number; + /** + * Maximum retry delay in milliseconds. + * + * Applies only when exponential backoff is used. + * + * This option applies only if the endpoint returns a stream of events. + * + * @default 30000 + */ + sseMaxRetryDelay?: number; + /** + * Optional sleep function for retry backoff. + * + * Defaults to using `setTimeout`. + */ + sseSleepFn?: (ms: number) => Promise; + url: string; + }; + +export interface StreamEvent { + data: TData; + event?: string; + id?: string; + retry?: number; +} + +export type ServerSentEventsResult< + TData = unknown, + TReturn = void, + TNext = unknown, +> = { + stream: AsyncGenerator< + TData extends Record ? TData[keyof TData] : TData, + TReturn, + TNext + >; +}; + +export const createSseClient = ({ + onSseError, + onSseEvent, + responseTransformer, + responseValidator, + sseDefaultRetryDelay, + sseMaxRetryAttempts, + sseMaxRetryDelay, + sseSleepFn, + url, + ...options +}: ServerSentEventsOptions): ServerSentEventsResult => { + let lastEventId: string | undefined; + + const sleep = + sseSleepFn ?? + ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + + const createStream = async function* () { + let retryDelay: number = sseDefaultRetryDelay ?? 3000; + let attempt = 0; + const signal = options.signal ?? new AbortController().signal; + + while (true) { + if (signal.aborted) break; + + attempt++; + + const headers = + options.headers instanceof Headers + ? options.headers + : new Headers(options.headers as Record | undefined); + + if (lastEventId !== undefined) { + headers.set('Last-Event-ID', lastEventId); + } + + try { + const response = await fetch(url, { ...options, headers, signal }); + + if (!response.ok) + throw new Error( + `SSE failed: ${response.status} ${response.statusText}`, + ); + + if (!response.body) throw new Error('No body in SSE response'); + + const reader = response.body + .pipeThrough(new TextDecoderStream()) + .getReader(); + + let buffer = ''; + + const abortHandler = () => { + try { + reader.cancel(); + } catch { + // noop + } + }; + + signal.addEventListener('abort', abortHandler); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += value; + + const chunks = buffer.split('\n\n'); + buffer = chunks.pop() ?? ''; + + for (const chunk of chunks) { + const lines = chunk.split('\n'); + const dataLines: Array = []; + let eventName: string | undefined; + + for (const line of lines) { + if (line.startsWith('data:')) { + dataLines.push(line.replace(/^data:\s*/, '')); + } else if (line.startsWith('event:')) { + eventName = line.replace(/^event:\s*/, ''); + } else if (line.startsWith('id:')) { + lastEventId = line.replace(/^id:\s*/, ''); + } else if (line.startsWith('retry:')) { + const parsed = Number.parseInt( + line.replace(/^retry:\s*/, ''), + 10, + ); + if (!Number.isNaN(parsed)) { + retryDelay = parsed; + } + } + } + + let data: unknown; + let parsedJson = false; + + if (dataLines.length) { + const rawData = dataLines.join('\n'); + try { + data = JSON.parse(rawData); + parsedJson = true; + } catch { + data = rawData; + } + } + + if (parsedJson) { + if (responseValidator) { + await responseValidator(data); + } + + if (responseTransformer) { + data = await responseTransformer(data); + } + } + + onSseEvent?.({ + data, + event: eventName, + id: lastEventId, + retry: retryDelay, + }); + + if (dataLines.length) { + yield data as any; + } + } + } + } finally { + signal.removeEventListener('abort', abortHandler); + reader.releaseLock(); + } + + break; // exit loop on normal completion + } catch (error) { + // connection failed or aborted; retry after delay + onSseError?.(error); + + if ( + sseMaxRetryAttempts !== undefined && + attempt >= sseMaxRetryAttempts + ) { + break; // stop after firing error + } + + // exponential backoff: double retry each attempt, cap at 30s + const backoff = Math.min( + retryDelay * 2 ** (attempt - 1), + sseMaxRetryDelay ?? 30000, + ); + await sleep(backoff); + } + } + }; + + const stream = createStream(); + + return { stream }; +}; diff --git a/lib/api/generated/core/types.ts b/lib/api/generated/core/types.gen.ts similarity index 75% rename from lib/api/generated/core/types.ts rename to lib/api/generated/core/types.gen.ts index 77d87925..643c070c 100644 --- a/lib/api/generated/core/types.ts +++ b/lib/api/generated/core/types.gen.ts @@ -1,33 +1,42 @@ -import type { Auth, AuthToken } from './auth'; +// This file is auto-generated by @hey-api/openapi-ts + +import type { Auth, AuthToken } from './auth.gen'; import type { BodySerializer, QuerySerializer, QuerySerializerOptions, -} from './bodySerializer'; +} from './bodySerializer.gen'; + +export type HttpMethod = + | 'connect' + | 'delete' + | 'get' + | 'head' + | 'options' + | 'patch' + | 'post' + | 'put' + | 'trace'; -export interface Client< +export type Client< RequestFn = never, Config = unknown, MethodFn = never, BuildUrlFn = never, -> { + SseFn = never, +> = { /** * Returns the final request URL. */ buildUrl: BuildUrlFn; - connect: MethodFn; - delete: MethodFn; - get: MethodFn; getConfig: () => Config; - head: MethodFn; - options: MethodFn; - patch: MethodFn; - post: MethodFn; - put: MethodFn; request: RequestFn; setConfig: (config: Config) => Config; - trace: MethodFn; -} +} & { + [K in HttpMethod]: MethodFn; +} & ([SseFn] extends [never] + ? { sse?: never } + : { sse: { [K in HttpMethod]: SseFn } }); export interface Config { /** @@ -63,16 +72,7 @@ export interface Config { * * {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more} */ - method?: - | 'CONNECT' - | 'DELETE' - | 'GET' - | 'HEAD' - | 'OPTIONS' - | 'PATCH' - | 'POST' - | 'PUT' - | 'TRACE'; + method?: Uppercase; /** * A function for serializing request query parameters. By default, arrays * will be exploded in form style, objects will be exploded in deepObject @@ -102,3 +102,17 @@ export interface Config { */ responseValidator?: (data: unknown) => Promise; } + +type IsExactlyNeverOrNeverUndefined = [T] extends [never] + ? true + : [T] extends [never | undefined] + ? [undefined] extends [T] + ? false + : true + : false; + +export type OmitNever> = { + [K in keyof T as IsExactlyNeverOrNeverUndefined extends true + ? never + : K]: T[K]; +}; diff --git a/lib/api/generated/core/utils.gen.ts b/lib/api/generated/core/utils.gen.ts new file mode 100644 index 00000000..ac31396f --- /dev/null +++ b/lib/api/generated/core/utils.gen.ts @@ -0,0 +1,114 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { QuerySerializer } from './bodySerializer.gen'; +import { + type ArraySeparatorStyle, + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, +} from './pathSerializer.gen'; + +export interface PathSerializer { + path: Record; + url: string; +} + +export const PATH_PARAM_RE = /\{[^{}]+\}/g; + +export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { + let url = _url; + const matches = _url.match(PATH_PARAM_RE); + if (matches) { + for (const match of matches) { + let explode = false; + let name = match.substring(1, match.length - 1); + let style: ArraySeparatorStyle = 'simple'; + + if (name.endsWith('*')) { + explode = true; + name = name.substring(0, name.length - 1); + } + + if (name.startsWith('.')) { + name = name.substring(1); + style = 'label'; + } else if (name.startsWith(';')) { + name = name.substring(1); + style = 'matrix'; + } + + const value = path[name]; + + if (value === undefined || value === null) { + continue; + } + + if (Array.isArray(value)) { + url = url.replace( + match, + serializeArrayParam({ explode, name, style, value }), + ); + continue; + } + + if (typeof value === 'object') { + url = url.replace( + match, + serializeObjectParam({ + explode, + name, + style, + value: value as Record, + valueOnly: true, + }), + ); + continue; + } + + if (style === 'matrix') { + url = url.replace( + match, + `;${serializePrimitiveParam({ + name, + value: value as string, + })}`, + ); + continue; + } + + const replaceValue = encodeURIComponent( + style === 'label' ? `.${value as string}` : (value as string), + ); + url = url.replace(match, replaceValue); + } + } + return url; +}; + +export const getUrl = ({ + baseUrl, + path, + query, + querySerializer, + url: _url, +}: { + baseUrl?: string; + path?: Record; + query?: Record; + querySerializer: QuerySerializer; + url: string; +}) => { + const pathUrl = _url.startsWith('/') ? _url : `/${_url}`; + let url = (baseUrl ?? '') + pathUrl; + if (path) { + url = defaultPathSerializer({ path, url }); + } + let search = query ? querySerializer(query) : ''; + if (search.startsWith('?')) { + search = search.substring(1); + } + if (search) { + url += `?${search}`; + } + return url; +}; diff --git a/lib/api/generated/sdk.gen.ts b/lib/api/generated/sdk.gen.ts index e1355d29..3aef0ebc 100644 --- a/lib/api/generated/sdk.gen.ts +++ b/lib/api/generated/sdk.gen.ts @@ -67,6 +67,9 @@ import type { StartLiveData, StartLiveResponses, StartLiveErrors, + StopLiveData, + StopLiveResponses, + StopLiveErrors, CreateUserData, CreateUserResponses, CreateUserErrors, @@ -716,6 +719,23 @@ export const startLive = ( }); }; +/** + * Stop Live + */ +export const stopLive = ( + options: Options, +) => { + return (options.client ?? _heyApiClient).post< + StopLiveResponses, + StopLiveErrors, + ThrowOnError + >({ + responseType: 'json', + url: '/live/stop-live', + ...options, + }); +}; + /** * Create User */ diff --git a/lib/api/generated/types.gen.ts b/lib/api/generated/types.gen.ts index 7e57d5fa..d65d42c2 100644 --- a/lib/api/generated/types.gen.ts +++ b/lib/api/generated/types.gen.ts @@ -850,6 +850,10 @@ export type FeedPost = { * Is Live */ is_live?: boolean; + /** + * Live Ended At + */ + live_ended_at?: string | null; /** * Is Space */ @@ -1274,15 +1278,15 @@ export type LinkPreviewData = { /** * Images */ - images: Array | null; + images?: Array | null; /** * Site Name */ - site_name: string | null; + site_name?: string | null; /** * Platform */ - platform: string | null; + platform?: string | null; }; /** @@ -1377,6 +1381,10 @@ export type LocationFeedPost = { * Is Live */ is_live?: boolean; + /** + * Live Ended At + */ + live_ended_at?: string | null; /** * Is Space */ @@ -2845,6 +2853,34 @@ export type StartLiveResponses = { export type StartLiveResponse2 = StartLiveResponses[keyof StartLiveResponses]; +export type StopLiveData = { + body?: never; + path?: never; + query: { + /** + * Room Name + */ + room_name: string; + }; + url: '/live/stop-live'; +}; + +export type StopLiveErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type StopLiveError = StopLiveErrors[keyof StopLiveErrors]; + +export type StopLiveResponses = { + /** + * Successful Response + */ + 200: unknown; +}; + export type CreateUserData = { body: CreateUserRequest; path?: never; @@ -3467,9 +3503,12 @@ export type GetUserProfileByUsernameError = export type GetUserProfileByUsernameResponses = { /** + * Response Get User Profile By Username * Successful Response */ - 200: User; + 200: { + [key: string]: unknown; + }; }; export type GetUserProfileByUsernameResponse = diff --git a/lib/constants.ts b/lib/constants.ts index 734c43a0..a1197754 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -48,4 +48,4 @@ export const FACT_CHECK_FEED_ID = '67bb256786841cb3e7074bcd'; export const NEWS_FEED_ID = '687960db5051460a7afd6e63'; export const CATEGORY_ID = '669e9a03dd31644abb767337'; -export const LOCATION_FEED_PAGE_SIZE = 10 \ No newline at end of file +export const LOCATION_FEED_PAGE_SIZE = 10; diff --git a/lib/haptics.ts b/lib/haptics.ts index d81eab7d..8abe2990 100644 --- a/lib/haptics.ts +++ b/lib/haptics.ts @@ -3,7 +3,6 @@ import * as Device from 'expo-device'; import { impactAsync, ImpactFeedbackStyle } from 'expo-haptics'; import { isIOS, isWeb } from './platform'; -import { toast } from '@backpackapp-io/react-native-toast'; export function useHaptics() { return React.useCallback( diff --git a/lib/share.ts b/lib/share.ts index 1f193c3d..34a6a483 100644 --- a/lib/share.ts +++ b/lib/share.ts @@ -1,7 +1,6 @@ import { Platform, Share } from 'react-native'; import { setStringAsync } from 'expo-clipboard'; import { isAndroid, isIOS } from '@/lib/platform'; -import { toast } from '@backpackapp-io/react-native-toast'; import { ShareOptions } from 'react-native-share'; /** * This function shares a URL using the native Share API if available, or copies it to the clipboard @@ -42,8 +41,5 @@ export async function shareText(text: string) { await Share.share({ message: text }); } else { await setStringAsync(text); - toast('Copied to clipboard', { - id: 'clipboard-check', - }); } } diff --git a/lib/utils.ts b/lib/utils.ts index 8857fb66..270a5135 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -6,6 +6,7 @@ import Constants from 'expo-constants'; import * as Device from 'expo-device'; import { Platform } from 'react-native'; import { LocationFeedPost, Task, UserVerification } from './interfaces'; +import * as Sentry from '@sentry/react-native'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); @@ -43,10 +44,7 @@ export async function sendPushNotification(expoPushToken: string) { console.log(response.statusText); } -function handleRegistrationError(errorMessage: string) { - alert(errorMessage); - throw new Error(errorMessage); -} +function handleRegistrationError(errorMessage: string) {} export async function registerForPushNotificationsAsync() { if (Platform.OS === 'android') { @@ -80,10 +78,10 @@ export async function registerForPushNotificationsAsync() { const pushTokenString = ( await Notifications.getExpoPushTokenAsync({ projectId }) ).data; - console.log(pushTokenString); return pushTokenString; } catch (e: unknown) { handleRegistrationError(`${e}`); + Sentry.captureException(e); } } else { // handleRegistrationError("Must use physical device for push notifications"); diff --git a/locales/en.json b/locales/en.json index 8cf91060..81f3489d 100644 --- a/locales/en.json +++ b/locales/en.json @@ -25,6 +25,8 @@ }, "common": { "save": "Save", + "live_stream_started": "Live stream started", + "live_stream_started_description": "You are now live", "profile_update_failed": "Profile update failed", "system_error": "System error", "session_expired": "Session expired", @@ -205,8 +207,7 @@ "process_step_4": "Sources are evaluated as supporting or non-supporting", "process_step_5": "We provide a summary, a factual accuracy score, and original sources", "limitations": "Limitations", - "limitations_description": "While our fact-checking system is comprehensive, it has limitations. Results depend on available online information, and some topics may have limited or conflicting sources. We recommend reviewing the provided sources to make your own assessment.", - "finalize_user_details": "Finalizing..." + "limitations_description": "While our fact-checking system is comprehensive, it has limitations. Results depend on available online information, and some topics may have limited or conflicting sources. We recommend reviewing the provided sources to make your own assessment." }, "errors": { "post_publish_failed": "Failed to publish post", diff --git a/locales/ka.json b/locales/ka.json index adf8d6c2..62600d9d 100644 --- a/locales/ka.json +++ b/locales/ka.json @@ -51,8 +51,10 @@ "loading": "แƒ˜แƒขแƒ•แƒ˜แƒ แƒ—แƒ”แƒ‘แƒ...", "fact_check_loading": "แƒ•แƒแƒ›แƒแƒฌแƒ›แƒ”แƒ‘แƒ—...", "error_colon": "แƒจแƒ”แƒชแƒ“แƒแƒ›แƒ: ", + "live_stream_started": "แƒšแƒแƒ˜แƒ•แƒ˜", + "live_stream_started_description": "แƒšแƒแƒ˜แƒ•แƒ˜ แƒ“แƒแƒฌแƒงแƒ”แƒ‘แƒฃแƒšแƒ˜แƒ", "live_stream_disconnected": "แƒšแƒแƒ˜แƒ• แƒกแƒขแƒ แƒ˜แƒ›แƒ˜ แƒ’แƒแƒ˜แƒ—แƒ˜แƒจแƒ", - "live_stream_unavailable": "แƒžแƒ˜แƒ แƒ“แƒแƒžแƒ˜แƒ แƒ˜ แƒ”แƒ—แƒ”แƒ แƒ˜ แƒ“แƒแƒกแƒ แƒฃแƒšแƒ“แƒ แƒแƒœ แƒแƒ  แƒแƒ แƒ˜แƒก แƒฎแƒ”แƒšแƒ›แƒ˜แƒกแƒแƒฌแƒ•แƒ“แƒแƒ›แƒ˜", + "live_stream_unavailable": "แƒžแƒ˜แƒ แƒ“แƒแƒžแƒ˜แƒ แƒ˜ แƒ”แƒ—แƒ”แƒ แƒ˜ แƒ“แƒแƒกแƒ แƒฃแƒšแƒ“แƒ, แƒ แƒแƒ›แƒแƒ“แƒ”แƒœแƒ˜แƒ›แƒ” แƒฌแƒฃแƒ—แƒจแƒ˜ แƒ˜แƒฎแƒ˜แƒšแƒแƒ•แƒ— แƒกแƒ แƒฃแƒš แƒ•แƒ˜แƒ“แƒ”แƒแƒก", "active": "แƒแƒฅแƒขแƒ˜แƒฃแƒ แƒ˜", "confirm_logout_title": "แƒœแƒแƒ›แƒ“แƒ•แƒ˜แƒšแƒแƒ“ แƒ’แƒกแƒฃแƒ แƒ— แƒ’แƒแƒ›แƒแƒกแƒ•แƒšแƒ?", "confirm_logout_description": "แƒ’แƒแƒ˜แƒ—แƒ•แƒแƒšแƒ˜แƒกแƒฌแƒ˜แƒœแƒ”แƒ— แƒ แƒแƒ› แƒ’แƒแƒ›แƒแƒกแƒ•แƒšแƒ˜แƒก แƒจแƒ”แƒ›แƒ“แƒ”แƒ’ แƒงแƒ•แƒ”แƒšแƒ แƒ—แƒฅแƒ•แƒ”แƒœแƒ˜ แƒกแƒแƒฃแƒ‘แƒแƒ แƒ˜ แƒฌแƒแƒ˜แƒจแƒšแƒ”แƒ‘แƒ", @@ -186,7 +188,21 @@ "contradicts": "แƒฃแƒแƒ แƒงแƒแƒคแƒก", "no_contradicts": "แƒแƒ แƒแƒ•แƒ˜แƒœ แƒฃแƒแƒ แƒงแƒแƒคแƒก", "analysis": "แƒแƒœแƒแƒšแƒ˜แƒ–แƒ˜", - "based_on_sources": "แƒฌแƒงแƒแƒ แƒแƒก แƒ›แƒ˜แƒฎแƒ”แƒ“แƒ•แƒ˜แƒ—" + "based_on_sources": "แƒฌแƒงแƒแƒ แƒแƒก แƒ›แƒ˜แƒฎแƒ”แƒ“แƒ•แƒ˜แƒ—", + "how_fact_checking_works": "แƒ แƒแƒ’แƒแƒ  แƒ›แƒฃแƒจแƒแƒแƒ‘แƒก แƒคแƒแƒฅแƒขแƒ”แƒ‘แƒ˜แƒก แƒจแƒ”แƒ›แƒแƒฌแƒ›แƒ”แƒ‘แƒ", + "overview": "แƒ›แƒ˜แƒ›แƒแƒฎแƒ˜แƒšแƒ•แƒ", + "overview_description": "แƒ แƒแƒ“แƒ”แƒกแƒแƒช แƒฎแƒ”แƒ“แƒแƒ•แƒ— แƒคแƒแƒฅแƒขแƒ”แƒ‘แƒ˜แƒก แƒจแƒ”แƒ›แƒแƒฌแƒ›แƒ”แƒ‘แƒแƒก แƒแƒœ แƒจแƒ”แƒฏแƒแƒ›แƒ”แƒ‘แƒแƒก, แƒฉแƒ•แƒ”แƒœ แƒ•แƒแƒแƒœแƒแƒšแƒ˜แƒ–แƒ”แƒ‘แƒ— แƒ•แƒ”แƒ‘ แƒ’แƒ•แƒ”แƒ แƒ“แƒ”แƒ‘แƒ˜แƒก, แƒกแƒแƒชแƒ˜แƒแƒšแƒฃแƒ แƒ˜ แƒ›แƒ”แƒ“แƒ˜แƒ˜แƒก แƒžแƒแƒกแƒขแƒ”แƒ‘แƒ˜แƒกแƒ แƒ“แƒ แƒกแƒฎแƒ•แƒ แƒฌแƒงแƒแƒ แƒแƒ”แƒ‘แƒ˜แƒก แƒจแƒ˜แƒœแƒแƒแƒ แƒกแƒก แƒคแƒแƒฅแƒขแƒแƒ‘แƒ แƒ˜แƒ•แƒ˜ แƒกแƒ˜แƒ–แƒฃแƒกแƒขแƒ˜แƒก แƒ“แƒแƒกแƒแƒ“แƒแƒกแƒขแƒฃแƒ แƒ”แƒ‘แƒšแƒแƒ“. แƒฉแƒ•แƒ”แƒœแƒ˜ แƒกแƒ˜แƒกแƒขแƒ”แƒ›แƒ แƒ”แƒซแƒ”แƒ‘แƒก แƒจแƒ”แƒกแƒแƒ‘แƒแƒ›แƒ˜แƒก แƒ›แƒ˜แƒ—แƒ˜แƒ—แƒ”แƒ‘แƒ”แƒ‘แƒก แƒ›แƒ แƒแƒ•แƒแƒš แƒ•แƒ”แƒ‘แƒกแƒแƒ˜แƒขแƒ–แƒ”, แƒ แƒแƒ—แƒ แƒจแƒ”แƒแƒคแƒแƒกแƒแƒก แƒ’แƒแƒœแƒชแƒฎแƒแƒ“แƒ”แƒ‘แƒ”แƒ‘แƒ˜ แƒ“แƒ แƒ›แƒแƒ’แƒแƒฌแƒแƒ“แƒแƒ— แƒกแƒ แƒฃแƒšแƒงแƒแƒคแƒ˜แƒšแƒ˜ แƒจแƒ”แƒคแƒแƒกแƒ”แƒ‘แƒ.", + "how_we_score": "แƒ แƒแƒ’แƒแƒ  แƒ•แƒ˜แƒ—แƒ•แƒšแƒ˜แƒ— แƒฅแƒฃแƒšแƒ”แƒ‘แƒก", + "how_we_score_description_1": "แƒฉแƒ•แƒ”แƒœแƒ˜ แƒคแƒแƒฅแƒขแƒแƒ‘แƒ แƒ˜แƒ•แƒ˜ แƒกแƒ˜แƒ–แƒฃแƒกแƒขแƒ˜แƒก แƒฅแƒฃแƒšแƒ แƒ›แƒ”แƒ แƒงแƒ”แƒแƒ‘แƒก 0-แƒ“แƒแƒœ 1-แƒ›แƒ“แƒ”, แƒ แƒแƒช แƒฌแƒแƒ แƒ›แƒแƒแƒ“แƒ’แƒ”แƒœแƒก แƒ แƒแƒ›แƒ“แƒ”แƒœแƒแƒ“ แƒ–แƒฃแƒกแƒขแƒ˜แƒ แƒ’แƒแƒœแƒชแƒฎแƒแƒ“แƒ”แƒ‘แƒ แƒ˜แƒœแƒขแƒ”แƒ แƒœแƒ”แƒขแƒจแƒ˜ แƒœแƒแƒžแƒแƒ•แƒœแƒ˜ แƒ›แƒขแƒ™แƒ˜แƒชแƒ”แƒ‘แƒฃแƒšแƒ”แƒ‘แƒ”แƒ‘แƒ˜แƒก แƒกแƒแƒคแƒฃแƒซแƒ•แƒ”แƒšแƒ–แƒ”. แƒ แƒแƒช แƒฃแƒคแƒ แƒ แƒ›แƒแƒฆแƒแƒšแƒ˜แƒ แƒฅแƒฃแƒšแƒ, แƒ›แƒ˜แƒ— แƒฃแƒคแƒ แƒ แƒคแƒแƒฅแƒขแƒแƒ‘แƒ แƒ˜แƒ•แƒแƒ“ แƒ–แƒฃแƒกแƒขแƒ˜แƒ แƒ’แƒแƒœแƒชแƒฎแƒแƒ“แƒ”แƒ‘แƒ.", + "how_we_score_description_2": "แƒ—แƒ˜แƒ—แƒแƒ”แƒฃแƒšแƒ˜ แƒ›แƒ˜แƒ—แƒ˜แƒ—แƒ”แƒ‘แƒ แƒ™แƒšแƒแƒกแƒ˜แƒคแƒ˜แƒชแƒ˜แƒ แƒ“แƒ”แƒ‘แƒ แƒ แƒแƒ’แƒแƒ แƒช \"แƒ›แƒฎแƒแƒ แƒ“แƒแƒ›แƒญแƒ”แƒ แƒ˜\" แƒแƒœ \"แƒแƒ แƒแƒ›แƒฎแƒแƒ แƒ“แƒแƒ›แƒญแƒ”แƒ แƒ˜\" แƒจแƒ”แƒกแƒแƒ›แƒแƒฌแƒ›แƒ”แƒ‘แƒ”แƒšแƒ˜ แƒ’แƒแƒœแƒชแƒฎแƒแƒ“แƒ”แƒ‘แƒ˜แƒกแƒ—แƒ•แƒ˜แƒก. แƒ”แƒก แƒ›แƒ˜แƒ—แƒ˜แƒ—แƒ”แƒ‘แƒ”แƒ‘แƒ˜ แƒžแƒ˜แƒ แƒ“แƒแƒžแƒ˜แƒ  แƒ’แƒแƒ•แƒšแƒ”แƒœแƒแƒก แƒแƒฎแƒ“แƒ”แƒœแƒก แƒกแƒแƒ‘แƒแƒšแƒแƒ แƒคแƒแƒฅแƒขแƒแƒ‘แƒ แƒ˜แƒ•แƒ˜ แƒกแƒ˜แƒ–แƒฃแƒกแƒขแƒ˜แƒก แƒจแƒ”แƒคแƒแƒกแƒ”แƒ‘แƒแƒ–แƒ”.", + "our_process": "แƒฉแƒ•แƒ”แƒœแƒ˜ แƒžแƒ แƒแƒชแƒ”แƒกแƒ˜", + "process_step_1": "แƒฉแƒ•แƒ”แƒœ แƒ•แƒแƒแƒœแƒแƒšแƒ˜แƒ–แƒ”แƒ‘แƒ— แƒ›แƒแƒกแƒแƒšแƒ˜แƒก แƒกแƒ แƒฃแƒš แƒขแƒ”แƒฅแƒกแƒขแƒฃแƒ  แƒจแƒ˜แƒœแƒแƒแƒ แƒกแƒก", + "process_step_2": "แƒฉแƒ•แƒ”แƒœแƒ˜ แƒกแƒ˜แƒกแƒขแƒ”แƒ›แƒ แƒแƒฎแƒแƒ แƒชแƒ˜แƒ”แƒšแƒ”แƒ‘แƒก แƒฆแƒ แƒ›แƒ แƒซแƒ˜แƒ”แƒ‘แƒแƒก แƒงแƒ•แƒ”แƒšแƒ แƒœแƒแƒฎแƒกแƒ”แƒœแƒ”แƒ‘แƒ˜ แƒ’แƒแƒœแƒชแƒฎแƒแƒ“แƒ”แƒ‘แƒ˜แƒกแƒ—แƒ•แƒ˜แƒก", + "process_step_3": "แƒฉแƒ•แƒ”แƒœ แƒ•แƒžแƒแƒฃแƒšแƒแƒ‘แƒ— แƒจแƒ”แƒกแƒแƒ‘แƒแƒ›แƒ˜แƒก แƒ›แƒ˜แƒ—แƒ˜แƒ—แƒ”แƒ‘แƒ”แƒ‘แƒก แƒ›แƒ แƒแƒ•แƒแƒšแƒ˜ แƒ•แƒ”แƒ‘แƒกแƒแƒ˜แƒขแƒ˜แƒ“แƒแƒœ", + "process_step_4": "แƒฌแƒงแƒแƒ แƒแƒ”แƒ‘แƒ˜ แƒคแƒแƒกแƒ“แƒ”แƒ‘แƒ แƒ แƒแƒ’แƒแƒ แƒช แƒ›แƒฎแƒแƒ แƒ“แƒแƒ›แƒญแƒ”แƒ แƒ˜ แƒแƒœ แƒแƒ แƒแƒ›แƒฎแƒแƒ แƒ“แƒแƒ›แƒญแƒ”แƒ แƒ˜", + "process_step_5": "แƒฉแƒ•แƒ”แƒœ แƒ’แƒ—แƒแƒ•แƒแƒ–แƒแƒ‘แƒ— แƒจแƒ”แƒฏแƒแƒ›แƒ”แƒ‘แƒแƒก, แƒคแƒแƒฅแƒขแƒแƒ‘แƒ แƒ˜แƒ•แƒ˜ แƒกแƒ˜แƒ–แƒฃแƒกแƒขแƒ˜แƒก แƒฅแƒฃแƒšแƒแƒก แƒ“แƒ แƒแƒ แƒ˜แƒ’แƒ˜แƒœแƒแƒš แƒฌแƒงแƒแƒ แƒแƒ”แƒ‘แƒก", + "limitations": "แƒจแƒ”แƒ–แƒฆแƒฃแƒ“แƒ•แƒ”แƒ‘แƒ˜", + "limitations_description": "แƒ›แƒ˜แƒฃแƒฎแƒ”แƒ“แƒแƒ•แƒแƒ“ แƒ˜แƒ›แƒ˜แƒกแƒ, แƒ แƒแƒ› แƒฉแƒ•แƒ”แƒœแƒ˜ แƒคแƒแƒฅแƒขแƒ”แƒ‘แƒ˜แƒก แƒจแƒ”แƒ›แƒแƒฌแƒ›แƒ”แƒ‘แƒ˜แƒก แƒกแƒ˜แƒกแƒขแƒ”แƒ›แƒ แƒงแƒแƒ•แƒšแƒ˜แƒกแƒ›แƒแƒ›แƒชแƒ•แƒ”แƒšแƒ˜แƒ, แƒ›แƒแƒก แƒแƒฅแƒ•แƒก แƒจแƒ”แƒ–แƒฆแƒฃแƒ“แƒ•แƒ”แƒ‘แƒ˜. แƒจแƒ”แƒ“แƒ”แƒ’แƒ”แƒ‘แƒ˜ แƒ“แƒแƒ›แƒแƒ™แƒ˜แƒ“แƒ”แƒ‘แƒฃแƒšแƒ˜แƒ แƒฎแƒ”แƒšแƒ›แƒ˜แƒกแƒแƒฌแƒ•แƒ“แƒแƒ› แƒแƒœแƒšแƒแƒ˜แƒœ แƒ˜แƒœแƒคแƒแƒ แƒ›แƒแƒชแƒ˜แƒแƒ–แƒ”, แƒ“แƒ แƒ–แƒแƒ’แƒ˜แƒ”แƒ แƒ— แƒ—แƒ”แƒ›แƒแƒก แƒจแƒ”แƒ˜แƒซแƒšแƒ”แƒ‘แƒ แƒฐแƒฅแƒแƒœแƒ“แƒ”แƒก แƒจแƒ”แƒ–แƒฆแƒฃแƒ“แƒ˜แƒšแƒ˜ แƒแƒœ แƒฃแƒ แƒ—แƒ˜แƒ”แƒ แƒ—แƒ’แƒแƒ›แƒแƒ›แƒ แƒ˜แƒชแƒฎแƒแƒ•แƒ˜ แƒฌแƒงแƒแƒ แƒแƒ”แƒ‘แƒ˜. แƒ’แƒ˜แƒ แƒฉแƒ”แƒ•แƒ—, แƒ’แƒแƒ“แƒแƒฎแƒ”แƒ“แƒแƒ— แƒ›แƒแƒฌแƒแƒ“แƒ”แƒ‘แƒฃแƒš แƒฌแƒงแƒแƒ แƒแƒ”แƒ‘แƒก แƒกแƒแƒ™แƒฃแƒ—แƒแƒ แƒ˜ แƒจแƒ”แƒคแƒแƒกแƒ”แƒ‘แƒ˜แƒก แƒ’แƒแƒกแƒแƒ™แƒ”แƒ—แƒ”แƒ‘แƒšแƒแƒ“." }, "errors": { "post_publish_failed": "แƒžแƒแƒกแƒขแƒ˜ แƒ•แƒ”แƒ  แƒ’แƒแƒ›แƒแƒฅแƒ•แƒ”แƒงแƒœแƒ“แƒ", @@ -202,7 +218,6 @@ "camera_permission_required": "แƒ™แƒแƒ›แƒ”แƒ แƒ˜แƒก แƒœแƒ”แƒ‘แƒ แƒกแƒแƒญแƒ˜แƒ แƒแƒ", "camera_permission_denied": "แƒ™แƒแƒ›แƒ”แƒ แƒ˜แƒก แƒœแƒ”แƒ‘แƒ แƒฃแƒแƒ แƒงแƒแƒคแƒ˜แƒšแƒ˜แƒ. แƒ’แƒ—แƒฎแƒแƒ•แƒ— แƒฉแƒแƒ แƒ—แƒแƒ— แƒกแƒ˜แƒกแƒขแƒ”แƒ›แƒ˜แƒก แƒžแƒแƒ แƒแƒ›แƒ”แƒขแƒ แƒ”แƒ‘แƒจแƒ˜", "failed_save_image": "แƒกแƒฃแƒ แƒแƒ—แƒ˜แƒก แƒจแƒ”แƒœแƒแƒฎแƒ•แƒ แƒ•แƒ”แƒ  แƒ›แƒแƒฎแƒ”แƒ แƒฎแƒ“แƒ", - "failed_paste_content": "แƒ™แƒแƒœแƒขแƒ”แƒœแƒขแƒ˜แƒก แƒฉแƒแƒกแƒ›แƒ แƒ•แƒ”แƒ  แƒ›แƒแƒฎแƒ”แƒ แƒฎแƒ“แƒ", - "finalize_user_details": "แƒ’แƒ—แƒฎแƒแƒ•แƒ— แƒ“แƒแƒ˜แƒชแƒแƒ“แƒแƒ—..." + "failed_paste_content": "แƒ™แƒแƒœแƒขแƒ”แƒœแƒขแƒ˜แƒก แƒฉแƒแƒกแƒ›แƒ แƒ•แƒ”แƒ  แƒ›แƒแƒฎแƒ”แƒ แƒฎแƒ“แƒ" } } diff --git a/openapi-ts.config.ts b/openapi-ts.config.ts new file mode 100644 index 00000000..075aa91a --- /dev/null +++ b/openapi-ts.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from '@hey-api/openapi-ts'; + +const shouldWatch = + process.argv.includes('--watch') || process.env.OPENAPI_WATCH === '1'; + +export default defineConfig({ + input: { + path: 'http://localhost:5500/openapi.json', + watch: shouldWatch, + }, + output: { + path: 'lib/api/generated', + format: 'prettier', + }, + plugins: [ + '@hey-api/typescript', + { + name: '@hey-api/sdk', + client: '@hey-api/client-axios', + }, + '@tanstack/react-query', + ], +}); diff --git a/package-lock.json b/package-lock.json index 208b0dcd..3e853c4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,14 @@ { "name": "ment-app", - "version": "1.0.22", + "version": "1.0.25", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ment-app", - "version": "1.0.22", + "version": "1.0.25", "hasInstallScript": true, "dependencies": { - "@backpackapp-io/react-native-toast": "^0.13.0", "@bacons/apple-targets": "^0.2.1", "@config-plugins/react-native-webrtc": "^10.0.0", "@expo/config-plugins": "^9.0.0", @@ -40,7 +39,7 @@ "@supabase/supabase-js": "^2.45.1", "@tanstack/react-query": "^5.51.23", "@uidotdev/usehooks": "^2.4.1", - "axios": "^1.7.4", + "axios": "^1.12.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "date-fns": "^3.6.0", @@ -124,6 +123,7 @@ "@types/lodash": "^4.17.16", "@types/react": "~18.3.12", "@types/uuid": "^10.0.0", + "husky": "^9.1.7", "jest": "^29.2.1", "prettier": "^3.6.2", "tailwindcss": "^3.3.5", @@ -2344,19 +2344,6 @@ "node": ">=6.9.0" } }, - "node_modules/@backpackapp-io/react-native-toast": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@backpackapp-io/react-native-toast/-/react-native-toast-0.13.0.tgz", - "integrity": "sha512-kutFSE1vi77ybNV24JSnKQ4WUgWZ+LcYsrdAyl5ztEWGP7FRsfinsuaOrBQwDBGxm0rzMFeKM5K7fRHa/Hvy8A==", - "license": "MIT", - "peerDependencies": { - "react": "*", - "react-native": "*", - "react-native-gesture-handler": ">=2.2.1", - "react-native-reanimated": ">=2.8.0", - "react-native-safe-area-context": ">=4.2.4" - } - }, "node_modules/@bacons/apple-targets": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/@bacons/apple-targets/-/apple-targets-0.2.1.tgz", @@ -7276,9 +7263,9 @@ } }, "node_modules/axios": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", - "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.0.tgz", + "integrity": "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -12057,6 +12044,22 @@ "node": ">=10.17.0" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/hyphenate-style-name": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz", diff --git a/package.json b/package.json index 4b2828bf..46d1f305 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ment-app", "main": "index.js", - "version": "1.0.22", + "version": "1.0.25", "scripts": { "start": "node ./scripts/start-with-local-ip.js", "reset-project": "node ./scripts/reset-project.js", @@ -23,8 +23,8 @@ "feature:start": "bash ./scripts/gitflow-feature-start.sh", "feature:finish": "bash ./scripts/gitflow-feature-finish.sh", "release:start": "bash ./scripts/gitflow-release-start.sh", - "release:preview": "bash ./scripts/gitflow-release-to-preview.sh", - "release:promote": "bash ./scripts/gitflow-promote-to-main.sh" + "release:promote": "bash ./scripts/gitflow-promote-to-main.sh", + "prepare": "husky" }, "expo": { "doctor": { @@ -34,7 +34,6 @@ } }, "dependencies": { - "@backpackapp-io/react-native-toast": "^0.13.0", "@bacons/apple-targets": "^0.2.1", "@config-plugins/react-native-webrtc": "^10.0.0", "@expo/config-plugins": "^9.0.0", @@ -65,7 +64,7 @@ "@supabase/supabase-js": "^2.45.1", "@tanstack/react-query": "^5.51.23", "@uidotdev/usehooks": "^2.4.1", - "axios": "^1.7.4", + "axios": "^1.12.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "date-fns": "^3.6.0", @@ -149,6 +148,7 @@ "@types/lodash": "^4.17.16", "@types/react": "~18.3.12", "@types/uuid": "^10.0.0", + "husky": "^9.1.7", "jest": "^29.2.1", "prettier": "^3.6.2", "tailwindcss": "^3.3.5", diff --git a/screens/mediapage.tsx b/screens/mediapage.tsx index aa4c6f1c..bd14f4fb 100644 --- a/screens/mediapage.tsx +++ b/screens/mediapage.tsx @@ -34,7 +34,6 @@ import SubmitButton from '@/components/SubmitButton'; import RetryButton from '@/components/RetryButton'; import Button from '@/components/Button'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import { toast } from '@backpackapp-io/react-native-toast'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useToast } from '@/components/ToastUsage'; import { t } from '@/lib/i18n'; @@ -76,7 +75,7 @@ export default function MediaPage(): React.ReactElement { 'none', ); - const { success } = useToast(); + const { success, dismiss } = useToast(); const [mediaPath, setMediaPath] = useState(null); const videoRef = useRef