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..e04224f3
--- /dev/null
+++ b/.github/workflows/production-deploy.yml
@@ -0,0 +1,176 @@
+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:
+ remote_update:
+ if: github.event.inputs.mode == 'remote_update'
+ runs-on: macos-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)
+ 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 }}
+
+ android:
+ if: github.event.inputs.mode != 'remote_update' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'android')
+ runs-on: macos-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_PROD_PLIST" > GoogleService-Info.plist
+ env:
+ GOOGLE_SERVICES_JSON: ${{ secrets.GOOGLE_SERVICES_JSON }}
+ GOOGLESERVICE_INFO_PROD_PLIST: ${{ secrets.GOOGLESERVICE_INFO_PROD_PLIST }}
+
+ - name: ๐ฑ Build Android (Production AAB)
+ 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
+ run: |
+ eas submit -p android --non-interactive --path ./app-production.apk
+ env:
+ EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
+ GOOGLE_PLAY_SERVICE_ACCOUNT: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT }}
+
+ ios:
+ if: github.event.inputs.mode != 'remote_update' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'ios')
+ runs-on: macos-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_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
+ 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 iOS (Production IPA)
+ 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
+ run: |
+ eas submit -p ios --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..fcbea1a0 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,7 +41,10 @@ env:
EXPO_APPLE_PASSWORD: ${{ secrets.EXPO_APPLE_PASSWORD }}
EXPO_TEAM_ID: ${{ secrets.EXPO_TEAM_ID }}
GOOGLE_PLAY_SERVICE_ACCOUNT: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT }}
- NODE_OPTIONS: --openssl-legacy-provider
+ GOOGLE_SERVICES_JSON: ${{ secrets.GOOGLE_SERVICES_JSON }}
+ GOOGLESERVICE_INFO_PROD_PLIST: ${{ secrets.GOOGLESERVICE_INFO_PROD_PLIST }}
+ NODE_OPTIONS: --openssl-legacy-provider --max_old_space_size=8192
+ GRADLE_OPTS: -Xmx4g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError
jobs:
check-skip:
@@ -61,10 +61,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 +81,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,50 +90,56 @@ 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: โ Setup Java with increased memory
+ if: matrix.platform == 'android'
+ uses: actions/setup-java@v4
+ with:
+ distribution: 'temurin'
+ java-version: '17'
+
+ - name: ๐ง Configure Gradle memory settings
+ if: matrix.platform == 'android'
+ run: |
+ mkdir -p ~/.gradle
+ echo "org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError" >> ~/.gradle/gradle.properties
+ echo "org.gradle.daemon=true" >> ~/.gradle/gradle.properties
+ echo "org.gradle.parallel=true" >> ~/.gradle/gradle.properties
+ echo "org.gradle.configureondemand=true" >> ~/.gradle/gradle.properties
+ echo "android.enableR8.fullMode=false" >> ~/.gradle/gradle.properties
+ echo "android.enableD8.desugaring=true" >> ~/.gradle/gradle.properties
+
- name: ๐ฆ Install dependencies
run: |
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 +161,41 @@ 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'))
+ 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-apk --local --non-interactive --output=./app-prod.apk
+ 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 }}
+ export GRADLE_OPTS="-Xmx4g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError"
+ export NODE_OPTIONS="--openssl-legacy-provider --max_old_space_size=8192"
+ eas build --platform android --profile preview --local --non-interactive --output ./app-preview.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'))
- 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
- 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'
- 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
- 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')
+ 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 production --local --non-interactive --output=./app-ios-prod.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 }}
+ export NODE_OPTIONS="--openssl-legacy-provider --max_old_space_size=8192"
+ eas build --platform ios --profile preview --local --non-interactive --output ./app-preview.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 +222,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..ec7cb9bb
--- /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/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
+[](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..965fd501 100644
--- a/app.config.js
+++ b/app.config.js
@@ -5,6 +5,123 @@ 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-video',
+ 'expo-router',
+ [
+ 'expo-share-intent',
+ {
+ iosActivationRules: {
+ NSExtensionActivationSupportsWebURLWithMaxCount: 1,
+ NSExtensionActivationSupportsWebPageWithMaxCount: 1,
+ NSExtensionActivationSupportsText: true,
+ NSExtensionActivationSupportsImageWithMaxCount: 10,
+ },
+ androidIntentFilters: ['text/*', 'image/*'],
+ },
+ ],
+
+ [
+ 'expo-notifications',
+ {
+ icon: './assets/images/small-icon-android.png',
+ color: '#000',
+ defaultChannel: 'default',
+ enableBackgroundRemoteNotifications: true,
+ },
+ ],
+ [
+ '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-localization',
+ {
+ supportedLocales: {
+ ios: ['en', 'fr', 'ka'],
+ android: ['en', 'fr', 'ka'],
+ },
+ },
+ ],
+ [
+ '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: {
+ deploymentTarget: '15.1',
+ newArchEnabled: true,
+ },
+ android: {
+ newArchEnabled: true,
+ },
+ },
+ ],
+ '@more-tech/react-native-libsodium',
+ // Removed react-native-share plugin; using RN Share API / expo-sharing instead
+ '@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'],
@@ -34,6 +151,8 @@ export default {
'This app uses the camera to capture photos and videos.',
NSPhotoLibraryUsageDescription:
'This app accesses your photos to let you share them.',
+ NSPhotoLibraryAddUsageDescription:
+ 'This app saves photos and videos to your photo library.',
NSMicrophoneUsageDescription:
'This app accesses your microphone to let you share them.',
NSContactsUsageDescription:
@@ -44,9 +163,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 +177,9 @@ export default {
backgroundColor: '#ffffff',
},
- googleServicesFile: './google-services.json',
+ googleServicesFile: !DISABLE_FIREBASE
+ ? './google-services.json'
+ : undefined,
intentFilters: [
{
action: 'VIEW',
@@ -80,133 +201,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)/_layout.tsx b/app/(auth)/_layout.tsx
new file mode 100644
index 00000000..199b197a
--- /dev/null
+++ b/app/(auth)/_layout.tsx
@@ -0,0 +1,31 @@
+import { Stack } from 'expo-router';
+import SimpleGoBackHeader from '@/components/SimpleGoBackHeader';
+import { useColorScheme } from '@/lib/useColorScheme';
+
+function Layout() {
+ return (
+
+ (
+
+ ),
+ }}
+ />
+
+
+ );
+}
+
+export default Layout;
diff --git a/app/(auth)/photos.tsx b/app/(auth)/photos.tsx
deleted file mode 100644
index 4635e96d..00000000
--- a/app/(auth)/photos.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import { View } from 'react-native';
-import { useSafeAreaInsets } from 'react-native-safe-area-context';
-import Photos from '@/components/Photos';
-
-export default function RegisterPhotos() {
- const insets = useSafeAreaInsets();
-
- return (
-
-
-
-
-
- );
-}
diff --git a/app/(auth)/register.tsx b/app/(auth)/register.tsx
index 1027c2f5..94ac145a 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/(camera)/_layout.tsx b/app/(camera)/_layout.tsx
new file mode 100644
index 00000000..62002c22
--- /dev/null
+++ b/app/(camera)/_layout.tsx
@@ -0,0 +1,20 @@
+import { useSession } from '@/components/AuthLayer';
+import { Stack } from 'expo-router';
+
+function Layout() {
+ const { session, isLoading, user, userIsLoading } = useSession();
+
+ if (isLoading || userIsLoading) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+ );
+}
+
+export default Layout;
diff --git a/app/(tabs)/(home)/[feedId]/livestream.tsx b/app/(camera)/livestream.tsx
similarity index 100%
rename from app/(tabs)/(home)/[feedId]/livestream.tsx
rename to app/(camera)/livestream.tsx
diff --git a/app/(chat)/[roomId]/index.tsx b/app/(chat)/[roomId]/index.tsx
new file mode 100644
index 00000000..565925c5
--- /dev/null
+++ b/app/(chat)/[roomId]/index.tsx
@@ -0,0 +1,29 @@
+import ScreenLoader from '@/components/ScreenLoader';
+import useAuth from '@/hooks/useAuth';
+import useMessageRoom from '@/hooks/useMessageRoom';
+import { Redirect, useGlobalSearchParams } from 'expo-router';
+import ErrorMessageCard from '@/components/ErrorMessageCard';
+import { ChatList } from '@/components/Chat/chat-list';
+
+export default function SharedChat() {
+ const { roomId } = useGlobalSearchParams();
+ const { room, isFetching } = useMessageRoom(roomId as string);
+ const { user } = useAuth();
+
+ if (isFetching) {
+ return ;
+ }
+ if (!room) {
+ return ;
+ }
+
+ const selectedUser = room?.participants.find((p) => p.id !== user.id) || null;
+ if (!selectedUser) {
+ return ;
+ }
+ return (
+ <>
+
+ >
+ );
+}
diff --git a/app/(tabs)/(home,user,fact-check,news)/profile-picture.tsx b/app/(chat)/[roomId]/profile-picture.tsx
similarity index 100%
rename from app/(tabs)/(home,user,fact-check,news)/profile-picture.tsx
rename to app/(chat)/[roomId]/profile-picture.tsx
diff --git a/app/(chat)/_layout.tsx b/app/(chat)/_layout.tsx
new file mode 100644
index 00000000..3e098e3a
--- /dev/null
+++ b/app/(chat)/_layout.tsx
@@ -0,0 +1,48 @@
+import { Stack } from 'expo-router';
+import SimpleGoBackHeader from '@/components/SimpleGoBackHeader';
+import ChatTopbar from '@/components/Chat/chat-topbar';
+import { View } from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import useKeyboardVerticalOffset from '@/hooks/useKeyboardVerticalOffset';
+import DbUserGetter from '@/components/DbUserGetter';
+import { useSession } from '@/components/AuthLayer';
+
+function Layout() {
+ const { session, isLoading, user, userIsLoading } = useSession();
+
+ const insets = useSafeAreaInsets();
+ if (isLoading || userIsLoading) {
+ return null;
+ }
+
+ return (
+
+
+
+ ,
+ }}
+ />
+ ,
+ }}
+ />
+
+
+
+ );
+}
+
+export default Layout;
diff --git a/app/(tabs)/(home,user,fact-check,news)/chatrooms/index.tsx b/app/(chat)/index.tsx
similarity index 100%
rename from app/(tabs)/(home,user,fact-check,news)/chatrooms/index.tsx
rename to app/(chat)/index.tsx
diff --git a/app/(tabs)/(chat-list)/_layout.tsx b/app/(tabs)/(chat-list)/_layout.tsx
new file mode 100644
index 00000000..3a5a47f8
--- /dev/null
+++ b/app/(tabs)/(chat-list)/_layout.tsx
@@ -0,0 +1,56 @@
+import { Stack } from 'expo-router';
+import SimpleGoBackHeader from '@/components/SimpleGoBackHeader';
+import { TouchableOpacity, View } from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import ContactSyncSheet from '@/components/ContactSyncSheet';
+import { RefObject, useRef } from 'react';
+import BottomSheet from '@gorhom/bottom-sheet';
+import { Ionicons } from '@expo/vector-icons';
+import { useTheme } from '@/lib/theme';
+
+function Layout() {
+ const insets = useSafeAreaInsets();
+ const contactSyncSheetRef = useRef(null);
+ const theme = useTheme();
+ return (
+
+
+ (
+ contactSyncSheetRef.current?.expand()}
+ >
+
+
+ }
+ />
+ ),
+ }}
+ />
+
+ }
+ />
+
+ );
+}
+
+export default Layout;
diff --git a/app/(tabs)/(chat-list)/index.tsx b/app/(tabs)/(chat-list)/index.tsx
new file mode 100644
index 00000000..1eeabc2f
--- /dev/null
+++ b/app/(tabs)/(chat-list)/index.tsx
@@ -0,0 +1,12 @@
+import React from 'react';
+import { View } from 'react-native';
+import ChatRoomList from '@/components/ChatRoomList';
+import ChatFriendsStories from '@/components/ChatFriendsStories';
+
+export default function TabTwoScreen() {
+ return (
+
+ } />
+
+ );
+}
diff --git a/app/(tabs)/(fact-check)/[feedId]/index.tsx b/app/(tabs)/(fact-check)/[feedId]/index.tsx
deleted file mode 100644
index 6c14ed6d..00000000
--- a/app/(tabs)/(fact-check)/[feedId]/index.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from '@/screens/home/locationfeed';
diff --git a/app/(tabs)/(fact-check)/_layout.tsx b/app/(tabs)/(fact-check)/_layout.tsx
deleted file mode 100644
index fbec0be6..00000000
--- a/app/(tabs)/(fact-check)/_layout.tsx
+++ /dev/null
@@ -1,122 +0,0 @@
-import React from 'react';
-import { Link, Stack, usePathname } from 'expo-router';
-import ProfileHeader from '@/components/ProfileHeader';
-import { TaskTitle } from '@/components/CustomTitle';
-import { ScrollReanimatedValueProvider } from '@/components/context/ScrollReanimatedValue';
-import { View, Text, Platform } from 'react-native';
-import { useSafeAreaInsets } from 'react-native-safe-area-context';
-import ChatTopbar from '@/components/Chat/chat-topbar';
-import { isIOS, isWeb } from '@/lib/platform';
-import SimpleGoBackHeader from '@/components/SimpleGoBackHeader';
-import SimpleGoBackHeaderPost from '@/components/SimpleGoBackHeaderPost';
-import { ProfilePageUsername } from '@/components/ProfilePageUsername';
-import { t } from '@/lib/i18n';
-
-export default function Layout() {
- const insets = useSafeAreaInsets();
-
- return (
-
-
-
- (
- }
- showSearch={true}
- showTabs={true}
- />
- ),
- }}
- />
- null,
- headerShown: false,
- }}
- />
- null,
- headerShown: false,
- }}
- />
- ,
- }}
- />
-
- ,
- }}
- />
-
- ,
- }}
- />
-
- ,
- }}
- />
-
- {
- const params = route.params as { verificationId?: string };
- const verificationId = params?.verificationId;
- return {
- title: '',
- headerTransparent: true,
- header: () => {
- return (
-
- );
- },
- };
- }}
- />
-
- (
-
- ),
- }}
- />
-
-
-
- );
-}
diff --git a/app/(tabs)/(home)/_layout.tsx b/app/(tabs)/(home)/_layout.tsx
deleted file mode 100644
index 90836026..00000000
--- a/app/(tabs)/(home)/_layout.tsx
+++ /dev/null
@@ -1,137 +0,0 @@
-import { Link, Stack, useLocalSearchParams } from 'expo-router';
-import ProfileHeader from '@/components/ProfileHeader';
-import { TaskTitle } from '@/components/CustomTitle';
-import { ScrollReanimatedValueProvider } from '@/components/context/ScrollReanimatedValue';
-import { View } from 'react-native';
-import { useSafeAreaInsets } from 'react-native-safe-area-context';
-import { ProfilePageUsername } from '@/components/ProfilePageUsername';
-import ChatTopbar from '@/components/Chat/chat-topbar';
-import { isIOS, isWeb } from '@/lib/platform';
-import SimpleGoBackHeader from '@/components/SimpleGoBackHeader';
-import SimpleGoBackHeaderPost from '@/components/SimpleGoBackHeaderPost';
-import LocationProvider from '@/components/LocationProvider';
-
-export default function Layout() {
- const insets = useSafeAreaInsets();
-
- return (
-
-
-
-
- {/* Optionally configure static options outside the route.*/}
- (
- } />
- ),
- }}
- />
- ,
- }}
- />
-
- ,
- }}
- />
- ,
- }}
- />
- ,
- }}
- />
-
- {
- const params = route.params as { verificationId?: string };
- const verificationId = params?.verificationId || '';
-
- return {
- headerTransparent: true,
- header: () => {
- return (
-
- );
- },
- };
- }}
- />
- (
- }
- showLocationTabs={true}
- showTabs={true}
- />
- ),
- }}
- />
-
- null,
- }}
- />
-
- null,
- headerShown: false,
- }}
- />
-
-
-
-
-
-
- );
-}
diff --git a/app/(tabs)/(home)/[feedId]/create-space/index.tsx b/app/(tabs)/(home)/create-space/index.tsx
similarity index 95%
rename from app/(tabs)/(home)/[feedId]/create-space/index.tsx
rename to app/(tabs)/(home)/create-space/index.tsx
index 5fa486c0..83c8141b 100644
--- a/app/(tabs)/(home)/[feedId]/create-space/index.tsx
+++ b/app/(tabs)/(home)/create-space/index.tsx
@@ -1,4 +1,3 @@
-// @ts-nocheck
import React, { useState, useEffect } from 'react';
import {
View,
@@ -108,7 +107,9 @@ export default function CreateSpace() {
size="default"
onPress={async () => {
await haptic('Medium');
- createSpace({ description, feedId });
+ createSpace({
+ body: { text_content: description, feed_id: feedId },
+ });
}}
disabled={isPending}
isLoading={isPending}
@@ -127,8 +128,8 @@ export default function CreateSpace() {
onPress={async () => {
await haptic('Light');
router.push({
- pathname: `/(tabs)/(home)/[feedId]/create-space/schedule-space`,
- params: { feedId, description },
+ pathname: `/(tabs)/(home)/create-space/schedule-space`,
+ params: { feedId, description: description },
});
}}
disabled={isPending}
@@ -147,7 +148,7 @@ const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: 'black',
- padding: 16,
+ paddingTop: 100,
},
contentContainer: {
flex: 1,
diff --git a/app/(tabs)/(home)/[feedId]/create-space/schedule-space.tsx b/app/(tabs)/(home)/create-space/schedule-space.tsx
similarity index 57%
rename from app/(tabs)/(home)/[feedId]/create-space/schedule-space.tsx
rename to app/(tabs)/(home)/create-space/schedule-space.tsx
index 16c2a6a3..ae29e4b5 100644
--- a/app/(tabs)/(home)/[feedId]/create-space/schedule-space.tsx
+++ b/app/(tabs)/(home)/create-space/schedule-space.tsx
@@ -1,7 +1,13 @@
// @ts-nocheck
import React, { useState } from 'react';
-import { View, TouchableOpacity, Text, StyleSheet } from 'react-native';
-import DatePicker from 'react-native-date-picker';
+import {
+ View,
+ TouchableOpacity,
+ Text,
+ StyleSheet,
+ Platform,
+} from 'react-native';
+import DateTimePicker from '@react-native-community/datetimepicker';
import { useLocalSearchParams, router, useRouter } from 'expo-router';
import { useCreateSpace } from '@/hooks/useCreateSpace';
import CustomAnimatedButton from '@/components/ui/AnimatedButton';
@@ -27,27 +33,41 @@ export default function ScheduleSpace() {
return (
- {
- setOpen(false);
- setSelectedDate(date);
- }}
- onCancel={() => {
- setOpen(false);
- router.back();
- }}
- />
+ {open && (
+ {
+ if (Platform.OS === 'android') {
+ if (event.type === 'set' && date) {
+ setSelectedDate(date);
+ setOpen(false);
+ } else {
+ setOpen(false);
+ router.back();
+ }
+ } else {
+ if (date) setSelectedDate(date);
+ }
+ }}
+ />
+ )}
+
+ {/* iOS cancel button to mimic previous cancel behavior */}
+ {Platform.OS === 'ios' && (
+ {
+ setOpen(false);
+ router.back();
+ }}
+ style={{ marginTop: 12 }}
+ >
+ Cancel
+
+ )}
{/* Schedule Button */}
{
- if (!isFetching && data) {
- // First try to navigate to a task at location
- if (data.feeds_at_location && data.feeds_at_location.length > 0) {
- const firstTask = data.feeds_at_location[0];
- router.replace({
- pathname: '/(tabs)/(home)/[feedId]',
- params: {
- feedId: firstTask.id,
- },
- });
- if (!isWeb) {
- goLiveMutation.mutateAsync({
- body: {
- feed_id: firstTask.id,
- },
- });
- }
- return;
- }
-
- // If no tasks at location, try nearest tasks
- if (data.nearest_feeds && data.nearest_feeds.length > 0) {
- const firstNearTask = data.nearest_feeds[0];
- router.replace({
+ console.log(defaultFeedId);
+ if (!isFetching && !errorMsg && !!defaultFeedId) {
+ return (
+
+ );
+ }
return (
,
+ }}
+ />,
+
+ {
+ const params = route.params as { verificationId?: string };
+ const verificationId = params?.verificationId;
+ return {
+ title: '',
+ headerTransparent: true,
+ header: () => {
+ return (
+
+ );
+ },
+ };
+ }}
+ />,
+
+ null,
+ headerShown: false,
+ }}
+ />,
+
+ ,
+ }}
+ />,
+ ];
+ if (isHomeFeed) {
+ screens.push(
+ ({
+ headerTransparent: !isWeb,
+ animation: 'fade',
+ header: () => (
+ }
+ showLocationTabs={true}
+ //@ts-ignore
+ feedId={route.params?.feedId}
+ />
+ ),
+ })}
+ />,
+ ({
+ headerTransparent: !isWeb,
+ header: () => ,
+ })}
+ />,
+ );
+ }
+
+ if (!isUserFeed) {
+ screens.push(
+ (
+
+ }
+ showSearch={!isUserFeed}
+ showLocationTabs={false}
+ showTabs={!isUserFeed}
+ feedId={undefined}
+ //@ts-ignore
+ content_type={route.params?.content_type || 'last24h'}
+ />
+ ),
+ }}
+ />,
+ );
+ }
+
+ if (isUserFeed) {
+ screens.push(
+ (
+
+ }
+ customButtons={
+
+
+
+
+
+
+
+ }
+ />
+ ),
+ }}
+ />,
+ );
+ screens.push(
+ ,
+ headerStyle: {
+ backgroundColor: theme.colors.background,
+ },
+ headerTintColor: theme.colors.text,
+ }}
+ />,
+ ,
+ ,
+ headerStyle: {
+ backgroundColor: theme.colors.background,
+ },
+ headerTintColor: theme.colors.text,
+ }}
+ />,
+
+ ,
+ (
+
+ ),
+ headerStyle: {
+ backgroundColor: theme.colors.background,
+ },
+ headerTintColor: theme.colors.text,
+ }}
+ />,
+ );
+ }
+
+ if (isHomeFeed) {
+ return (
+
+
+
+ {screens}
+
+
+
+ );
+ }
+ return (
+
+
+
+ {screens}
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ paddingHorizontal: 24,
+ },
+ loadingContainer: {
+ alignItems: 'center',
+ gap: 20,
+ },
+ statusContainer: {
+ alignItems: 'center',
+ maxWidth: 280,
+ },
+ loadingText: {
+ fontSize: FontSizes.medium,
+ textAlign: 'center',
+ fontWeight: '500',
+ },
+ errorText: {
+ fontSize: FontSizes.medium,
+ textAlign: 'center',
+ lineHeight: 22,
+ fontWeight: '400',
+ },
+ emptyText: {
+ fontSize: FontSizes.medium,
+ textAlign: 'center',
+ fontWeight: '400',
+ },
+});
diff --git a/app/(tabs)/(home,user,fact-check,news)/[feedId]/create-post-shareintent.tsx b/app/(tabs)/(home,user)/create-post-shareintent.tsx
similarity index 100%
rename from app/(tabs)/(home,user,fact-check,news)/[feedId]/create-post-shareintent.tsx
rename to app/(tabs)/(home,user)/create-post-shareintent.tsx
diff --git a/app/(tabs)/(home,user,fact-check,news)/[feedId]/create-post.tsx b/app/(tabs)/(home,user)/create-post.tsx
similarity index 100%
rename from app/(tabs)/(home,user,fact-check,news)/[feedId]/create-post.tsx
rename to app/(tabs)/(home,user)/create-post.tsx
diff --git a/app/(tabs)/(home,user,fact-check,news)/fact-checks.tsx b/app/(tabs)/(home,user)/fact-checks.tsx
similarity index 100%
rename from app/(tabs)/(home,user,fact-check,news)/fact-checks.tsx
rename to app/(tabs)/(home,user)/fact-checks.tsx
diff --git a/app/(tabs)/(home,user)/profile-picture.tsx b/app/(tabs)/(home,user)/profile-picture.tsx
new file mode 100644
index 00000000..e9c4158a
--- /dev/null
+++ b/app/(tabs)/(home,user)/profile-picture.tsx
@@ -0,0 +1,14 @@
+import ProfilePicturePage from '@/components/ProfilePicturePage';
+import { usePathname } from 'expo-router';
+
+export default function ProfilePicture() {
+ const pathname = usePathname();
+ const showMessageOption =
+ pathname.includes('feed') || pathname.includes('notifications');
+
+ return (
+ <>
+
+ >
+ );
+}
diff --git a/app/(tabs)/(home,user,fact-check,news)/profile.tsx b/app/(tabs)/(home,user)/profile.tsx
similarity index 100%
rename from app/(tabs)/(home,user,fact-check,news)/profile.tsx
rename to app/(tabs)/(home,user)/profile.tsx
diff --git a/app/(tabs)/(home,user,fact-check,news)/verification/[verificationId].tsx b/app/(tabs)/(home,user)/verification/[verificationId].tsx
similarity index 95%
rename from app/(tabs)/(home,user,fact-check,news)/verification/[verificationId].tsx
rename to app/(tabs)/(home,user)/verification/[verificationId].tsx
index 494d9942..4be9d69a 100644
--- a/app/(tabs)/(home,user,fact-check,news)/verification/[verificationId].tsx
+++ b/app/(tabs)/(home,user)/verification/[verificationId].tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
+import { useLocalSearchParams } from 'expo-router';
import { View, Text, ActivityIndicator } from 'react-native';
import useVerificationById from '@/hooks/useVerificationById';
import CommentsView from '@/components/VerificationView/CommentsView';
@@ -9,6 +9,7 @@ function VerificationView() {
const params = useLocalSearchParams<{
verificationId: string;
}>();
+
const color = useThemeColor({}, 'text');
// Runtime check and type assertion
diff --git a/app/(tabs)/(home,user,fact-check,news)/chatrooms/[roomId]/index.tsx b/app/(tabs)/(home,user,fact-check,news)/chatrooms/[roomId]/index.tsx
deleted file mode 100644
index a6731656..00000000
--- a/app/(tabs)/(home,user,fact-check,news)/chatrooms/[roomId]/index.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import Chat from '@/components/Chat';
-import ChatTopbar from '@/components/Chat/chat-topbar';
-import ScreenLoader from '@/components/ScreenLoader';
-import useAuth from '@/hooks/useAuth';
-import useMessageRoom from '@/hooks/useMessageRoom';
-import { publicKeyState } from '@/lib/state/auth';
-import { useAtom } from 'jotai';
-import { Stack, useGlobalSearchParams } from 'expo-router';
-import MessageConnectionWrapper from '@/components/Chat/socket/MessageConnectionWrapper';
-import ErrorMessageCard from '@/components/ErrorMessageCard';
-// THis component only used for the navigation from notification to not brake routing
-
-export default function SharedChat() {
- const { roomId } = useGlobalSearchParams();
- const { room, isFetching } = useMessageRoom(roomId as string);
- const { user } = useAuth();
-
- if (isFetching) {
- return ;
- }
- if (!room) {
- return (
-
- );
- }
-
- const selectedUser = room?.participants.find((p) => p.id !== user.id) || null;
- if (!selectedUser) {
- return ;
- }
- return (
- <>
-
- >
- );
-}
diff --git a/app/(tabs)/(news)/[feedId]/index.tsx b/app/(tabs)/(news)/[feedId]/index.tsx
deleted file mode 100644
index 6c14ed6d..00000000
--- a/app/(tabs)/(news)/[feedId]/index.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from '@/screens/home/locationfeed';
diff --git a/app/(tabs)/(news)/_layout.tsx b/app/(tabs)/(news)/_layout.tsx
deleted file mode 100644
index 6bcca19a..00000000
--- a/app/(tabs)/(news)/_layout.tsx
+++ /dev/null
@@ -1,118 +0,0 @@
-import React from 'react';
-import { Stack } from 'expo-router';
-import ProfileHeader from '@/components/ProfileHeader';
-import { TaskTitle } from '@/components/CustomTitle';
-import { ScrollReanimatedValueProvider } from '@/components/context/ScrollReanimatedValue';
-import { View } from 'react-native';
-import { useSafeAreaInsets } from 'react-native-safe-area-context';
-import ChatTopbar from '@/components/Chat/chat-topbar';
-import { isIOS, isWeb } from '@/lib/platform';
-import SimpleGoBackHeader from '@/components/SimpleGoBackHeader';
-import SimpleGoBackHeaderPost from '@/components/SimpleGoBackHeaderPost';
-import { ProfilePageUsername } from '@/components/ProfilePageUsername';
-
-export default function Layout() {
- const insets = useSafeAreaInsets();
-
- return (
-
-
-
- (
- }
- showSearch={true}
- />
- ),
- }}
- />
- null,
- headerShown: false,
- }}
- />
- null,
- headerShown: false,
- }}
- />
- ,
- }}
- />
-
- ,
- }}
- />
-
- ,
- }}
- />
-
- ,
- }}
- />
-
- {
- const params = route.params as { verificationId?: string };
- const verificationId = params?.verificationId;
- return {
- title: '',
- headerTransparent: true,
- header: () => {
- return (
-
- );
- },
- };
- }}
- />
-
- ,
- }}
- />
-
-
-
- );
-}
diff --git a/app/(tabs)/(user)/_layout.tsx b/app/(tabs)/(user)/_layout.tsx
deleted file mode 100644
index 9c4ddfef..00000000
--- a/app/(tabs)/(user)/_layout.tsx
+++ /dev/null
@@ -1,146 +0,0 @@
-import { TabBarIcon } from '@/components/navigation/TabBarIcon';
-import ProfileHeader from '@/components/ProfileHeader';
-import SimpleGoBackHeader from '@/components/SimpleGoBackHeader';
-import useAuth from '@/hooks/useAuth';
-import { Link, Stack, useRouter } from 'expo-router';
-import { TouchableOpacity, View, ActivityIndicator } from 'react-native';
-import { useSafeAreaInsets } from 'react-native-safe-area-context';
-import { useTheme } from '@/lib/theme';
-import SimpleGoBackHeaderPost from '@/components/SimpleGoBackHeaderPost';
-import React from 'react';
-import { CustomTitle } from '@/components/CustomTitle';
-import { t } from '@/lib/i18n';
-
-export default function Layout() {
- const { user } = useAuth();
- const insets = useSafeAreaInsets();
- const theme = useTheme();
-
- return (
-
-
- (
-
- }
- customButtons={
-
-
-
-
-
-
-
- }
- />
- ),
- }}
- />
-
- {
- const params = route.params as { verificationId?: string };
- const verificationId = params?.verificationId;
- return {
- headerTransparent: true,
- header: () => {
- return (
-
- );
- },
- };
- }}
- />
-
- (
-
- ),
- headerStyle: {
- backgroundColor: theme.colors.background,
- },
- headerTintColor: theme.colors.text,
- }}
- />
-
-
-
- ,
- headerStyle: {
- backgroundColor: theme.colors.background,
- },
- headerTintColor: theme.colors.text,
- }}
- />
- (
-
- ),
- headerStyle: {
- backgroundColor: theme.colors.background,
- },
- headerTintColor: theme.colors.text,
- }}
- />
-
-
- );
-}
diff --git a/app/(tabs)/(user)/profile-settings.tsx b/app/(tabs)/(user)/profile-settings.tsx
index 579c72db..cdaee4e6 100644
--- a/app/(tabs)/(user)/profile-settings.tsx
+++ b/app/(tabs)/(user)/profile-settings.tsx
@@ -33,6 +33,12 @@ import { FontSizes, useTheme } from '@/lib/theme';
import SimpleGoBackHeader from '@/components/SimpleGoBackHeader';
import { useThemeColor } from '@/hooks/useThemeColor';
import { t } from '@/lib/i18n';
+import * as Updates from 'expo-updates';
+import {
+ getApiBaseUrl as getApiBaseUrlFromConfig,
+ setApiBaseUrl as setApiBaseUrlInConfig,
+ API_BASE_URL as DEFAULT_API_BASE_URL,
+} from '@/lib/api/config';
const formSchema = z
.object({
@@ -78,6 +84,9 @@ export default function Component() {
const router = useRouter();
const theme = useTheme();
const colorScheme = useRNColorScheme() || 'dark';
+ const isNonProduction = __DEV__ || Updates.channel !== 'production';
+
+ const [apiBaseUrl, setApiBaseUrl] = React.useState(getApiBaseUrlFromConfig());
useEffect(() => {
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..6e6ef8ce 100644
--- a/app/(tabs)/_layout.tsx
+++ b/app/(tabs)/_layout.tsx
@@ -1,47 +1,112 @@
-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 LocationProvider from '@/components/LocationProvider';
-import SpacesBottomSheet from '@/components/SpacesBottomSheet';
-import { Lightbox } from '@/components/Lightbox/Lightbox';
+import { isAndroid, isWeb } from '@/lib/platform';
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';
import { useTheme } from '@/lib/theme';
import { trackScreen, setUserProperties } from '@/lib/analytics';
-import { getCurrentLocale } from '@/lib/i18n';
+import { getCurrentLocale, t } from '@/lib/i18n';
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 { useAtom, 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';
+import { activeLivekitRoomState } from '@/components/SpacesBottomSheet/atom';
+import SpacesBottomSheet from '@/components/SpacesBottomSheet';
+import { useDefaultCountry } from '@/hooks/useDefaultCountry';
+import { toastStyles } from '@/lib/styles';
+import { useToast } from '@/components/ToastUsage';
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import CountryChangeToast from '@/components/CountryChangeToast';
+import { getCountryByCode } from '@/lib/countries';
+import { updateUser, UpdateUserRequest } from '@/lib/api/generated';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+
+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();
- const isRecord = pathname.includes('record');
- const { factCheckFeedId, newsFeedId } = useFeeds();
+ const { categoryId } = useFeeds();
const { isDarkColorScheme } = useColorScheme();
-
+ const queryClient = useQueryClient();
// Track screen changes and update user properties
useEffect(() => {
const screenName = pathname.replace('/', '').split('?')[0] || 'root';
@@ -50,6 +115,7 @@ export default function TabLayout() {
app_language: getCurrentLocale?.() || 'en',
});
}, [pathname]);
+
const theme = useTheme();
// Theme-aware tab colors
const TAB_COLORS = {
@@ -57,89 +123,98 @@ export default function TabLayout() {
inactive: isDarkColorScheme ? '#777777' : '#999999', // Dark gray in dark mode, lighter gray in light mode
};
- const recordingTabStyles = {
- tabBarButton: () => null,
- tabBarButtonComponent: () => null,
- tabBarLabel: () => null,
- };
-
const { colorScheme } = useColorScheme();
useEffect(() => {
setAndroidNavigationBar(colorScheme);
}, [colorScheme]);
- const { session, isLoading, user, userIsLoading } = useSession();
-
- const { closeLightbox } = useLightboxControls();
- const { shareIntent, resetShareIntent } = useShareIntentContext();
- const router = useRouter();
- const setUserLocationBottomSheet = useSetAtom(locationUserListSheetState);
- const setIsFactCheckBottomSheetOpen = useSetAtom(factCheckBottomSheetState);
-
+ const { session, isLoading, user, userIsLoading, setAuthUser } = useSession();
+ const updateUserMutation = useMutation({
+ mutationFn: (values: UpdateUserRequest) =>
+ updateUser({
+ body: {
+ ...values,
+ },
+ }),
+ onSuccess: () => {
+ // queryClient.resetQueries();
+ },
+ onError: (error) => {},
+ });
+ const {
+ country: selectedCountry,
+ setCountry,
+ newsFeedId: countryNewsFeedId,
+ factCheckFeedId: countryFactCheckFeedId,
+ } = useDefaultCountry();
+ const { show, dismiss } = useToast();
useEffect(() => {
- if (shareIntent && session && isAndroid) {
- // Check if we have images or text content to share
- const hasContent =
- shareIntent.text || (shareIntent.files && shareIntent.files.length > 0);
-
- if (hasContent) {
- // Filter for image files if any
- const imageFiles =
- shareIntent.files?.filter(
- (file) =>
- file.mimeType?.startsWith('image/') ||
- file.fileName?.match(/\.(jpg|jpeg|png|gif|webp)$/i),
- ) || [];
+ if (
+ countryNewsFeedId &&
+ user?.preferred_news_feed_id === countryNewsFeedId
+ ) {
+ // Do not show toast if user has the IP country
+ return;
+ }
- // Convert share intent files to the format expected by create-post
- const convertedImages = imageFiles.map((file) => ({
- uri: file.path,
- width: file.width || 0,
- height: file.height || 0,
- fileSize: file.size,
- type: 'image' as const,
- fileName: file.fileName || `shared_image_${Date.now()}.jpg`,
- mimeType: file.mimeType || 'image/jpeg',
- exif: null,
- assetId: null,
- base64: null,
- duration: null,
- }));
+ if (!countryFactCheckFeedId || !countryNewsFeedId) {
+ // Do not show toast if country feed IDs are not available
+ return;
+ }
- // Encode the images as URL parameters if we have any
- const encodedImages =
- convertedImages.length > 0
- ? encodeURIComponent(JSON.stringify(convertedImages))
- : '';
+ let toastId: string | null = null;
+ if (!selectedCountry?.code) return;
- // Build navigation params
- const params: any = {
- feedId: factCheckFeedId,
- disableRoomCreation: 'true',
- };
+ const storageKey = `country-toast-dismissed-${selectedCountry.code}`;
- if (shareIntent.text) {
- params.sharedContent = shareIntent.text;
- }
+ const maybeShowToast = async () => {
+ try {
+ const hasDismissed = await AsyncStorage.getItem(storageKey);
+ // if (hasDismissed === 'true') return;
- if (encodedImages) {
- params.sharedImages = encodedImages;
- }
+ const countryMeta = getCountryByCode(selectedCountry.code);
+ toastId = show(
+ {
+ updateUserMutation.mutate({
+ preferred_news_feed_id: countryNewsFeedId,
+ preferred_fact_check_feed_id: countryFactCheckFeedId,
+ });
+ await AsyncStorage.setItem(storageKey, 'true');
+ if (toastId) dismiss(toastId);
+ }}
+ onDismiss={async () => {
+ await AsyncStorage.setItem(storageKey, 'true');
+ if (toastId) dismiss(toastId);
+ }}
+ onPress={() => {
+ // Optionally navigate to country settings in the future
+ }}
+ />,
+ {
+ position: 'top',
+ type: 'info',
+ duration: 0, // persistent until action
+ },
+ );
+ } catch (e) {
+ // ignore storage errors
+ }
+ };
- setUserLocationBottomSheet(false);
- setIsFactCheckBottomSheetOpen(false);
+ // maybeShowToast();
- // Navigate to create-post-shareintent for any shared content (text or images)
- router.navigate({
- pathname: `/(tabs)/(fact-check)/[feedId]/create-post-shareintent`,
- params,
- });
- }
+ return () => {
+ if (toastId) dismiss(toastId);
+ };
+ }, [selectedCountry?.code]);
- resetShareIntent();
- }
- }, [shareIntent, session]);
+ const { closeLightbox } = useLightboxControls();
+ const router = useRouter();
+ const isUserLive = useAtomValue(isUserLiveState);
useEffect(() => {
if (isAndroid) {
@@ -152,29 +227,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;
@@ -215,42 +267,35 @@ export default function TabLayout() {
// If on web, show a completely different layout with sidebar
return (
-
-
-
-
-
-
-
+
+
+
);
}
-
// Otherwise, default to existing tabs on mobile.
return (
-
+
null,
freezeOnBlur: true,
headerTransparent: true,
- ...(isRecord && recordingTabStyles),
tabBarActiveTintColor: TAB_COLORS.active,
tabBarInactiveTintColor: TAB_COLORS.inactive,
tabBarShowLabel: false,
@@ -258,7 +303,6 @@ export default function TabLayout() {
backgroundColor: theme.colors.background,
borderTopColor: theme.colors.border,
},
- // tabBarHideOnKeyboard: isIOS,
}}
>
(
-
- ),
+ tabBarIcon: ({ color, focused }) =>
+ isUserLive ? (
+
+
+
+ ) : (
+
+ ),
}}
/>
(
+ tabBarIcon: ({ focused }) => (
),
}}
/>
- (
-
- ),
- }}
- />
-
({
+ tabPress: (e) => {
+ router.dismissAll();
+ router.navigate({
+ pathname: '/(tabs)/(user)',
+ });
+ },
+ })}
options={{
title: '',
tabBarIcon: ({ color, focused }) => (
@@ -346,14 +366,9 @@ export default function TabLayout() {
),
}}
/>
-
+
diff --git a/app/(tabs)/shareintent.tsx b/app/(tabs)/shareintent.tsx
deleted file mode 100644
index 9b6edb3a..00000000
--- a/app/(tabs)/shareintent.tsx
+++ /dev/null
@@ -1,205 +0,0 @@
-import React, { useEffect, useRef } from 'react';
-import {
- Image,
- StyleSheet,
- Text,
- View,
- TouchableOpacity,
- ActivityIndicator,
-} from 'react-native';
-import { Redirect, useRouter } from 'expo-router';
-import {
- ShareIntent as ShareIntentType,
- useShareIntentContext,
-} from 'expo-share-intent';
-import { useSession } from '@/components/AuthLayer';
-import { useSafeAreaInsets } from 'react-native-safe-area-context';
-import useFeeds from '@/hooks/useFeeds';
-import { toast } from '@backpackapp-io/react-native-toast';
-import { useToast } from '@/components/ToastUsage';
-
-export default function ShareIntent() {
- const insets = useSafeAreaInsets();
- const { session, isLoading } = useSession();
- const { factCheckFeedId, newsFeedId } = useFeeds();
- const { shareIntent } = useShareIntentContext();
- const sharedContent = shareIntent?.text;
- const sharedFiles = shareIntent?.files;
- const router = useRouter();
- const { error } = useToast();
-
- useEffect(() => {
- if (shareIntent && !session) {
- error({ title: 'แแแฎแแแ แจแแฎแแแแแ แกแแกแขแแแแจแ แแแกแแแ แซแแแแแแแ' });
- } else if (shareIntent && session) {
- // Check if we have images or text content to share
- const hasContent =
- sharedContent || (sharedFiles && sharedFiles.length > 0);
- if (hasContent) {
- // Filter for image files if any
- const imageFiles =
- sharedFiles?.filter(
- (file) =>
- file.mimeType?.startsWith('image/') ||
- file.fileName?.match(/\.(jpg|jpeg|png|gif|webp)$/i),
- ) || [];
-
- // Convert share intent files to the format expected by create-post
- const convertedImages = imageFiles.map((file) => ({
- uri: file.path,
- width: file.width || 0,
- height: file.height || 0,
- fileSize: file.size,
- type: 'image' as const,
- fileName: file.fileName || `shared_image_${Date.now()}.jpg`,
- mimeType: file.mimeType || 'image/jpeg',
- exif: null,
- assetId: null,
- base64: null,
- duration: null,
- }));
-
- // Encode the images as URL parameters if we have any
- const encodedImages =
- convertedImages.length > 0
- ? encodeURIComponent(JSON.stringify(convertedImages))
- : '';
-
- // Build navigation params
- const params: any = {
- feedId: factCheckFeedId,
- disableRoomCreation: 'true',
- };
-
- if (sharedContent) {
- params.sharedContent = sharedContent;
- }
-
- if (encodedImages) {
- params.sharedImages = encodedImages;
- }
-
- // Navigate to create-post-shareintent for any shared content (text or images)
- router.replace({
- pathname:
- `/(tabs)/(fact-check)/${factCheckFeedId}/create-post-shareintent` as any,
- params,
- });
- }
- }
- }, [shareIntent, session, sharedContent, sharedFiles, factCheckFeedId]);
-
- if (isLoading) {
- return (
-
-
-
- );
- }
-
- if (session) {
- return null;
- }
-
- return (
-
- ShareIntent
-
- );
-}
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- backgroundColor: '#000',
- paddingHorizontal: 16,
- },
- contentContainer: {
- flex: 1,
- alignItems: 'center',
- justifyContent: 'center',
- width: '100%',
- },
- loginContainer: {
- flex: 1,
- alignItems: 'center',
- justifyContent: 'center',
- width: '100%',
- paddingHorizontal: 24,
- gap: 24,
- },
- loginTitle: {
- fontSize: 28,
- fontWeight: 'bold',
- color: 'white',
- textAlign: 'center',
- marginBottom: 12,
- },
- loginSubtitle: {
- fontSize: 18,
- color: '#D1D5DB',
- textAlign: 'center',
- marginBottom: 24,
- },
- headerText: {
- fontSize: 22,
- fontWeight: 'bold',
- color: 'white',
- marginBottom: 24,
- },
- contentText: {
- fontSize: 16,
- color: '#D1D5DB',
- marginBottom: 16,
- textAlign: 'center',
- },
- contentCard: {
- borderRadius: 12,
- backgroundColor: '#1C1C1E',
- overflow: 'hidden',
- marginBottom: 20,
- width: '100%',
- borderWidth: 1,
- borderColor: '#2C2C2E',
- },
- cardTitle: {
- fontSize: 18,
- fontWeight: '600',
- color: 'white',
- marginBottom: 8,
- },
- cardText: {
- fontSize: 14,
- color: '#D1D5DB',
- },
- image: {
- width: '100%',
- height: 240,
- borderRadius: 12,
- marginBottom: 20,
- resizeMode: 'cover',
- },
- row: {
- flexDirection: 'row',
- gap: 10,
- },
- error: {
- color: '#FF453A',
- marginTop: 12,
- },
- button: {
- flexDirection: 'row',
- justifyContent: 'center',
- alignItems: 'center',
- backgroundColor: '#efefef',
- borderRadius: 12,
- marginTop: 16,
- width: '100%',
- padding: 16,
- },
- buttonText: {
- color: 'black',
- fontSize: 20,
- fontWeight: '600',
- },
-});
diff --git a/app/_layout.tsx b/app/_layout.tsx
index 7231f423..81e2e572 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -1,19 +1,18 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
-import { Slot } from 'expo-router';
-import { StatusBar } from 'expo-status-bar';
+import { Slot, Stack, useNavigationContainerRef } from 'expo-router';
import * as React from 'react';
import { AppState, AppStateStatus, Platform } from 'react-native';
import { NAV_THEME } from '~/lib/constants';
import { useColorScheme } from '~/lib/useColorScheme';
import { PortalHost } from '@/components/primitives/portal';
-import AuthLayer, { useSession } from '@/components/AuthLayer';
+import AuthLayer from '@/components/AuthLayer';
import * as Notifications from 'expo-notifications';
import * as Sentry from '@sentry/react-native';
import { createStore, Provider, useAtom } from 'jotai';
-import { isDev } from '@/lib/api/config';
+import { isDev, SENTRY_DSN } from '@/lib/api/config';
import AppStateHandler from '../components/AppStateHandler';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
-import { useState, useEffect } from 'react';
+import { useEffect } from 'react';
import { useOTAUpdates } from '@/hooks/useOTAUpdates';
import {
focusManager,
@@ -34,8 +33,18 @@ import {
} from '@react-navigation/native';
import { appLocaleAtom } from '@/hooks/useAppLocalization';
import { getCurrentLocale, setLocale } from '@/lib/i18n';
+import StatusBarRenderer from '@/components/StatusBarRenderer';
+import { useReactNavigationDevTools } from '@dev-plugins/react-navigation';
+import { useSyncQueriesExternal } from 'react-query-external-sync';
+import * as ExpoDevice from 'expo-device';
+import * as NavigationBar from 'expo-navigation-bar';
function AppLocaleGate({ children }: { children: React.ReactNode }) {
+ useNotificationHandler();
+ const navigationRef = useNavigationContainerRef();
+
+ useReactNavigationDevTools(navigationRef);
+
const [appLocale, setAppLocale] = useAtom(appLocaleAtom);
useEffect(() => {
@@ -77,9 +86,10 @@ export {
Notifications.setNotificationHandler({
handleNotification: async (notification) => {
return {
- shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
+ shouldShowBanner: true,
+ shouldShowList: true,
};
},
});
@@ -95,7 +105,7 @@ SplashScreen.setOptions({
});
Sentry.init({
enabled: !isDev,
- dsn: 'https://8e8adf1963b62dfff57f9484ba1028f9@o4506526616453120.ingest.us.sentry.io/4507883615092736',
+ dsn: SENTRY_DSN,
// Set tracesSampleRate to 1.0 to capture 100%
// of transactions for tracing.
@@ -128,12 +138,22 @@ import NetInfo from '@react-native-community/netinfo';
import { onlineManager } from '@tanstack/react-query';
import { Lightbox } from '@/components/Lightbox/Lightbox';
import { ToastProviderWithViewport } from '@/components/ToastUsage';
+import LocationProvider from '@/components/LocationProvider';
+import Constants from 'expo-constants';
+import SimplifiedVideoPlayback from '@/components/SimplifiedVideoPlayback';
+import SimpleGoBackHeader from '@/components/SimpleGoBackHeader';
+import { useNotificationHandler } from '@/components/DbUserGetter/useNotficationHandler';
+// Import Platform for React Native or use other platform detection for web/desktop
onlineManager.setEventListener((setOnline) => {
return NetInfo.addEventListener((state) => {
setOnline(!!state.isConnected);
});
});
+// Get the host IP address dynamically
+const hostIP =
+ Constants.expoGoConfig?.debuggerHost?.split(`:`)[0] ||
+ Constants.expoConfig?.hostUri?.split(`:`)[0];
export default function RootLayout() {
const [appIsReady, setAppIsReady] = useAtom(appIsReadyState);
@@ -142,6 +162,36 @@ export default function RootLayout() {
// Initialize and keep app localization in sync
const [appLocale, setAppLocale] = useAtom(appLocaleAtom);
+ // Create your query client
+ // Set up the sync hook - automatically disabled in production!
+ // useSyncQueriesExternal({
+ // queryClient,
+ // socketURL: `http://${hostIP}:42831`, // Use local network IP
+ // // Default port for React Native DevTools
+ // deviceName: Platform?.OS || 'web', // Platform detection
+ // platform: Platform?.OS || 'web', // Use appropriate platform identifier
+ // deviceId: Platform?.OS || 'web', // Use a PERSISTENT identifier (see note below)
+ // isDevice: ExpoDevice.isDevice, // Automatically detects real devices vs emulators
+ // extraDeviceInfo: {
+ // // Optional additional info about your device
+ // appVersion: '1.0.0',
+ // // Add any relevant platform info
+ // },
+ // enableLogs: true,
+ // envVariables: {
+ // NODE_ENV: process.env.NODE_ENV,
+ // // Add any private environment variables you want to monitor
+ // // Public environment variables are automatically loaded
+ // },
+ // // Storage monitoring with CRUD operations
+ // asyncStorage: AsyncStorage, // AsyncStorage for ['#storage', 'async', 'key'] queries + monitoring
+ // secureStorageKeys: [
+ // 'userToken',
+ // 'refreshToken',
+ // 'biometricKey',
+ // 'deviceId',
+ // ], // SecureStore keys to monitor
+ // });
// Use the new OTA updates hook
useOTAUpdates();
@@ -211,35 +261,59 @@ export default function RootLayout() {
-
-
-
+
+
-
-
-
- {Platform.OS === 'android' && (
-
- )}
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/index.tsx b/app/index.tsx
index 2170514b..f20af716 100644
--- a/app/index.tsx
+++ b/app/index.tsx
@@ -24,7 +24,8 @@ export default function Index() {
}, [session]);
if (session && !userIsLoading && user && user.preferred_news_feed_id) {
- return ;
+ // This fires when user is signed in the application and app was fully closed. We don't specifiy the route to navigate here as it is dictacted by the tab layout tab
+ return ;
}
if (isLoading || userIsLoading) {
// Splash screen is anyway shown here with above useEffect
diff --git a/app/status/[verificationId].tsx b/app/status/[verificationId].tsx
index 38b874c3..77238b41 100644
--- a/app/status/[verificationId].tsx
+++ b/app/status/[verificationId].tsx
@@ -51,7 +51,7 @@ function VerificationView() {
paddingBottom: insets.bottom,
}}
>
-
+
(null);
-export const activeTabAtom = atom('recent');
+export const activeTabAtom = atom<'recent' | 'top'>('recent');
export const shouldFocusCommentInputAtom = atom(false);
diff --git a/babel.config.js b/babel.config.js
deleted file mode 100644
index 0c653a11..00000000
--- a/babel.config.js
+++ /dev/null
@@ -1,7 +0,0 @@
-module.exports = function (api) {
- api.cache(true);
- return {
- presets: [['babel-preset-expo']],
- plugins: ['react-native-reanimated/plugin'],
- };
-};
diff --git a/components/AccessView/index.tsx b/components/AccessView/index.tsx
index c7983bde..9c9c47b1 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';
@@ -95,7 +87,7 @@ export const CustomBottomSheetBackground = ({ style }: any) => {
style={[
style,
{
- backgroundColor: isDark ? 'black' : '#efefef', // Dark background for both modes
+ backgroundColor: isDark ? 'black' : '#ddd', // Dark background for both modes
borderTopLeftRadius: 10,
borderTopRightRadius: 10,
},
@@ -126,7 +118,7 @@ const TimerButton = React.memo(
resetTimer,
}: TimerButtonProps) => {
const [timer, setTimer] = useState(0);
- const timerRef = useRef(null);
+ const timerRef = useRef | null>(null);
const startTimer = useCallback((duration: number) => {
if (timerRef.current) {
@@ -136,7 +128,7 @@ const TimerButton = React.memo(
timerRef.current = setInterval(() => {
setTimer((prev) => {
if (prev <= 1) {
- clearInterval(timerRef.current as NodeJS.Timeout);
+ clearInterval(timerRef.current as unknown as NodeJS.Timeout);
return 0;
}
return prev - 1;
@@ -189,7 +181,6 @@ const TimerButton = React.memo(
style={{ marginTop: 12 }}
onPress={onPress}
disabled={isButtonDisabled && !isDev}
- variant="outline"
size="large"
glassy={true}
loading={isPending}
@@ -212,11 +203,7 @@ const SignupForm = forwardRef(function SignupForm(
);
const locale = getCurrentLocale();
const theme = useTheme();
- const {
- country: selectedCountry,
- isLoading,
- setCountry,
- } = useDefaultCountry();
+ const { country: selectedCountry, setCountry } = useDefaultCountry();
const [shouldStartTimer, setShouldStartTimer] = useState(0);
const [shouldResetTimer, setShouldResetTimer] = useState(false);
@@ -239,6 +226,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/AnimatedPressable/index.tsx b/components/AnimatedPressable/index.tsx
index a7a97bec..c3f90abe 100644
--- a/components/AnimatedPressable/index.tsx
+++ b/components/AnimatedPressable/index.tsx
@@ -8,17 +8,20 @@ export default function AnimatedPressable({
className,
onPress,
style,
+ disabled,
}: {
children: React.ReactNode;
onClick?: () => void;
className?: string;
onPress?: () => void;
style?: ViewStyle;
+ disabled?: boolean;
}) {
const [scale] = useState(new Animated.Value(1));
const theme = useTheme();
const handlePressIn = () => {
+ if (disabled) return;
Animated.spring(scale, {
toValue: 0.98,
useNativeDriver: true,
@@ -27,6 +30,7 @@ export default function AnimatedPressable({
};
const handlePressOut = () => {
+ if (disabled) return;
Animated.spring(scale, {
toValue: 1,
friction: 5,
@@ -39,14 +43,16 @@ export default function AnimatedPressable({
{children}
diff --git a/components/AuthLayer.tsx b/components/AuthLayer.tsx
index e2c07e26..0717053f 100644
--- a/components/AuthLayer.tsx
+++ b/components/AuthLayer.tsx
@@ -7,6 +7,7 @@ import { useRouter } from 'expo-router';
import ProtocolService from '@/lib/services/ProtocolService';
import { createUser, getUser, User } from '@/lib/api/generated';
import useSendPublicKey from '@/hooks/useSendPublicKey';
+import { getDeviceId } from '@/lib/device-id';
import { isWeb } from '@/lib/platform';
import { useToast } from './ToastUsage';
import { getCurrentLocale, getLanguageFromLocale, t } from '@/lib/i18n';
@@ -48,34 +49,76 @@ export const isUserRegistered = (user: User) => {
return !!user.date_of_birth && !!user.gender;
};
+// Retry utility with exponential backoff
+async function retryWithBackoff(
+ operation: () => Promise,
+ maxRetries: number = 3,
+ baseDelay: number = 500,
+ shouldRetry: (error: any) => boolean = (error) => {
+ // Retry on network errors, 5xx server errors, and rate limiting
+ const status = error?.response?.status;
+ return !status || status >= 500 || status === 429;
+ },
+): Promise {
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
+ try {
+ return await operation();
+ } catch (error) {
+ if (attempt === maxRetries || !shouldRetry(error)) {
+ throw error;
+ }
+
+ const backoff = baseDelay * Math.pow(2, attempt);
+ const jitter = Math.random() * 100;
+ const delay = backoff + jitter;
+
+ console.warn(
+ `Attempt ${attempt + 1} failed. Retrying in ${delay.toFixed(0)}ms...`,
+ error,
+ );
+ await new Promise((resolve) => setTimeout(resolve, delay));
+ }
+ }
+ throw new Error('Retry logic error'); // Should never reach here
+}
+
async function handleUserNotFound(supabaseUser: any) {
const currentLocale = getCurrentLocale();
const language = getLanguageFromLocale(currentLocale);
- return await createUser({
- body: {
- external_user_id: supabaseUser.id,
- email: supabaseUser.email || supabaseUser?.phone,
- phone_number: supabaseUser?.phone,
- date_of_birth: '',
- gender: null,
- username: '',
- photos: [],
- interests: [],
- city: null,
- preferred_content_language: language,
+
+ return await retryWithBackoff(
+ () =>
+ createUser({
+ body: {
+ external_user_id: supabaseUser.id,
+ email: supabaseUser.email || supabaseUser?.phone,
+ phone_number: supabaseUser?.phone,
+ date_of_birth: '',
+ gender: null,
+ username: '',
+ photos: [],
+ interests: [],
+ city: null,
+ preferred_content_language: language,
+ },
+ throwOnError: true,
+ }),
+ 15, // maxRetries
+ 500, // baseDelay
+ (error) => {
+ // Only retry on system errors, not client errors
+ const status = error?.response?.status;
+ return !status || status >= 500 || status === 429;
},
- throwOnError: true,
- });
+ );
}
export default function AuthLayer({ children }: { children: React.ReactNode }) {
- const queryClient = useQueryClient();
const [session, setSession] = useState(null);
const [isLoading, setIsLoading] = useState(!isWeb);
const [userIsLoading, setUserIsLoading] = useState(false);
const [error, setError] = useState(null);
const [user, setUser] = useState();
- const router = useRouter();
const { sendPublicKey } = useSendPublicKey();
const { error: errorToast, success: successToast, dismissAll } = useToast();
@@ -87,11 +130,11 @@ 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 });
+ (async () => {
+ const deviceId = await getDeviceId();
+ sendPublicKey({ userId: user.id, deviceId });
+ })();
}
}, [user]);
@@ -101,9 +144,25 @@ export default function AuthLayer({ children }: { children: React.ReactNode }) {
if (!session) return;
try {
setUserIsLoading(true);
- const dbUser = await getUser({
- throwOnError: true,
- });
+
+ const dbUser = await retryWithBackoff(
+ () => getUser({ throwOnError: true }),
+ 15, // maxRetries
+ 500, // baseDelay
+ (error) => {
+ // Only retry on system errors, not 404 or auth errors
+ const status = error?.response?.status;
+ return (
+ !status ||
+ (status >= 500 &&
+ status !== 404 &&
+ status !== 401 &&
+ status !== 403) ||
+ status === 429
+ );
+ },
+ );
+
setUserIsLoading(false);
setUser(dbUser.data);
// Get region so that application knows which feed ids to load
@@ -115,14 +174,12 @@ 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();
setUserIsLoading(false);
} catch (innerError) {
dismissAll();
- setUserIsLoading(false);
errorToast({ title: t('common.system_error') });
console.error('Error creating new user (inner):', innerError);
}
@@ -131,12 +188,11 @@ export default function AuthLayer({ children }: { children: React.ReactNode }) {
errorToast({ title: t('common.session_expired') });
await supabase.auth.signOut();
} else {
- setUserIsLoading(false);
dismissAll();
- errorToast({ title: t('common.session_expired') });
-
+ errorToast({ title: t('common.system_error') });
console.error('Error fetching user:', e);
}
+ setUserIsLoading(false);
}
}
fetchUser();
@@ -181,7 +237,7 @@ export default function AuthLayer({ children }: { children: React.ReactNode }) {
session: session || null,
}}
>
-
+ {/* */}
{children}
);
diff --git a/components/AutoSizedImage.tsx b/components/AutoSizedImage.tsx
index 1f6838f0..a87c8935 100644
--- a/components/AutoSizedImage.tsx
+++ b/components/AutoSizedImage.tsx
@@ -122,59 +122,6 @@ export function AutoSizedImage({
}
}}
/>
-
- {(hasAlt || isCropped) && !hideBadge ? (
-
- {isCropped && (
-
- {/* Fullscreen icon placeholder */}
-
-
- )}
- {hasAlt && (
-
- {/* ALT text placeholder */}
-
-
- )}
-
- ) : null}
);
diff --git a/components/BottomLocationActions/index.tsx b/components/BottomLocationActions/index.tsx
index 388f3c6d..141cda86 100644
--- a/components/BottomLocationActions/index.tsx
+++ b/components/BottomLocationActions/index.tsx
@@ -3,38 +3,29 @@ import { TouchableOpacity, View, Platform, StyleSheet } from 'react-native';
import TakeVideo from '../TakeVideo';
import LiveUserCountIndicator from '../LiveUserCountIndicator';
import useCountAnonList from '../LiveUserCountIndicator/useCountAnonList';
-import CreatePostGlobal from '../CreatePostGlobal';
import { useColorScheme } from '@/lib/useColorScheme';
import { useSetAtom } from 'jotai';
-import {
- locationUserListSheetState,
- locationUserListfeedIdState,
-} from '@/lib/atoms/location';
+import { locationUserListSheetState } from '@/lib/atoms/location';
import { isIOS } from '@/lib/platform';
import { trackEvent } from '@/lib/analytics';
export default function BottomLocationActions({
feedId,
isUserInSelectedLocation,
- isFactCheckFeed,
}: {
feedId: string;
onExpandLiveUsers?: () => void; // Make this optional
isUserInSelectedLocation: boolean;
- isFactCheckFeed: boolean;
}) {
const { data } = useCountAnonList(feedId);
const { isDarkColorScheme } = useColorScheme();
const setIsBottomSheetOpen = useSetAtom(locationUserListSheetState);
- const setfeedId = useSetAtom(locationUserListfeedIdState);
const handlePress = () => {
trackEvent('location_feed_live_users_button_pressed', {});
- console.log('handlePress called with feedId:', feedId, data);
setIsBottomSheetOpen(false);
if (data && data.count > 0) {
- setfeedId(feedId);
setIsBottomSheetOpen(true);
}
};
@@ -42,38 +33,27 @@ export default function BottomLocationActions({
const bottomPosition = 20;
return (
<>
- {isFactCheckFeed ? (
-
+
-
-
- ) : (
-
-
-
-
+
+
-
-
- )}
+
+
>
);
}
diff --git a/components/Button/index.tsx b/components/Button/index.tsx
index 3abd0adb..009721b0 100644
--- a/components/Button/index.tsx
+++ b/components/Button/index.tsx
@@ -36,6 +36,8 @@ export interface ButtonProps {
icon?: React.ComponentProps['name'];
/** Where to render the icon relative to the text. */
iconPosition?: 'left' | 'right';
+ /** Ionicons icon color. */
+ iconColor?: string;
/** Disables the button. */
disabled?: boolean;
/** Loading state replaces contents with ActivityIndicator. */
@@ -54,6 +56,7 @@ export default function Button({
fullWidth = false,
icon,
iconPosition = 'left',
+ iconColor = 'white',
disabled = false,
loading = false,
glassy = false,
@@ -210,7 +213,7 @@ export default function Button({
)}
@@ -231,7 +234,7 @@ export default function Button({
)}
diff --git a/components/CameraPage/CaptureButton.tsx b/components/CameraPage/CaptureButton.tsx
index 8cd9e26b..ab5264e3 100644
--- a/components/CameraPage/CaptureButton.tsx
+++ b/components/CameraPage/CaptureButton.tsx
@@ -1,8 +1,13 @@
-import React, { useCallback, useEffect, useRef, useState } from 'react';
+import React, {
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+ useMemo,
+} from 'react';
import type { ViewProps } from 'react-native';
import { StyleSheet, View } from 'react-native';
-import type { TapGestureHandlerStateChangeEvent } from 'react-native-gesture-handler';
-import { State, TapGestureHandler } from 'react-native-gesture-handler';
+import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Reanimated, {
cancelAnimation,
Easing,
@@ -12,11 +17,11 @@ import Reanimated, {
useSharedValue,
withRepeat,
interpolate,
+ runOnJS,
} from 'react-native-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 +58,7 @@ const _CaptureButton: React.FC = ({
const recordingProgress = useSharedValue(0);
const recordingTimer = useRef | null>(null);
const haptic = useHaptics();
+ const { dismiss } = useToast();
useEffect(() => {
setRecordingTimeView(isRecording);
@@ -140,32 +146,35 @@ const _CaptureButton: React.FC = ({
feedId,
]);
- const onHandlerStateChanged = useCallback(
- async ({ nativeEvent: event }: TapGestureHandlerStateChangeEvent) => {
- console.debug(`state: ${Object.keys(State)[event.state]}`);
- if (event.state === State.ACTIVE) {
- isPressingButton.value = true;
- setIsPressingButton(true);
+ const handleButtonPress = useCallback(() => {
+ setIsPressingButton(true);
- if (isRecording) {
- await stopRecording();
- } else {
- startRecording();
- }
+ if (isRecording) {
+ stopRecording();
+ } else {
+ startRecording();
+ }
- setTimeout(() => {
+ setTimeout(() => {
+ setIsPressingButton(false);
+ }, 200);
+ }, [isRecording, startRecording, stopRecording, setIsPressingButton]);
+
+ const tapGesture = useMemo(
+ () =>
+ Gesture.Tap()
+ .enabled(enabled)
+ .shouldCancelWhenOutside(false)
+ .onBegin(() => {
+ isPressingButton.value = true;
+ })
+ .onEnd(() => {
+ runOnJS(handleButtonPress)();
+ })
+ .onFinalize(() => {
isPressingButton.value = false;
- setIsPressingButton(false);
- }, 200);
- }
- },
- [
- isRecording,
- startRecording,
- stopRecording,
- isPressingButton,
- setIsPressingButton,
- ],
+ }),
+ [enabled, isPressingButton, handleButtonPress],
);
const buttonStyle = useAnimatedStyle(() => {
@@ -228,11 +237,7 @@ const _CaptureButton: React.FC = ({
});
return (
-
+
= ({
/>
-
+
);
};
diff --git a/components/CameraPage/CaptureButtonPhoto.tsx b/components/CameraPage/CaptureButtonPhoto.tsx
index 871b5474..5ee97ad7 100644
--- a/components/CameraPage/CaptureButtonPhoto.tsx
+++ b/components/CameraPage/CaptureButtonPhoto.tsx
@@ -1,14 +1,14 @@
-import React, { useCallback, useRef } from 'react';
+import React, { useCallback, useRef, useMemo } from 'react';
import type { ViewProps } from 'react-native';
import { StyleSheet, View } from 'react-native';
-import type { TapGestureHandlerStateChangeEvent } from 'react-native-gesture-handler';
-import { State, TapGestureHandler } from 'react-native-gesture-handler';
+import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Reanimated, {
Easing,
useAnimatedStyle,
withSpring,
withTiming,
useSharedValue,
+ runOnJS,
} from 'react-native-reanimated';
import type { Camera, PhotoFile } from 'react-native-vision-camera';
import { CAPTURE_BUTTON_SIZE } from './Constants';
@@ -37,7 +37,8 @@ const _CaptureButton: React.FC = ({
const isPressingButton = useSharedValue(false);
const photoRef = useRef(null);
const haptic = useHaptics();
- const takePhoto = useCallback(async () => {
+
+ const handlePhotoTaken = useCallback(async () => {
try {
if (camera.current == null) throw new Error('Camera ref is null!');
@@ -55,38 +56,36 @@ const _CaptureButton: React.FC = ({
} catch (e) {
console.error('Failed to take photo!', e);
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
+ } finally {
+ isPressingButton.value = false;
+ setIsPressingButton(false);
+ if (photoRef.current) {
+ onMediaCaptured(photoRef.current, 'photo');
+ photoRef.current = null;
+ }
}
- }, [camera, flash, onMediaCaptured]);
+ }, [
+ camera,
+ flash,
+ haptic,
+ isPressingButton,
+ setIsPressingButton,
+ onMediaCaptured,
+ ]);
- const onHandlerStateChanged = useCallback(
- async ({ nativeEvent: event }: TapGestureHandlerStateChangeEvent) => {
- console.debug(`state: ${Object.keys(State)[event.state]}`);
- switch (event.state) {
- case State.BEGAN: {
+ const tapGesture = useMemo(
+ () =>
+ Gesture.Tap()
+ .enabled(enabled)
+ .shouldCancelWhenOutside(false)
+ .onBegin(() => {
isPressingButton.value = true;
- setIsPressingButton(true);
- return;
- }
- case State.END:
- case State.FAILED:
- case State.CANCELLED: {
- try {
- await takePhoto();
- } finally {
- isPressingButton.value = false;
- setIsPressingButton(false);
- if (photoRef.current) {
- onMediaCaptured(photoRef.current, 'photo');
- photoRef.current = null;
- }
- }
- return;
- }
- default:
- break;
- }
- },
- [isPressingButton, setIsPressingButton, takePhoto],
+ runOnJS(setIsPressingButton)(true);
+ })
+ .onEnd(() => {
+ runOnJS(handlePhotoTaken)();
+ }),
+ [enabled, isPressingButton, setIsPressingButton, handlePhotoTaken],
);
const buttonStyle = useAnimatedStyle(() => {
@@ -124,15 +123,11 @@ const _CaptureButton: React.FC = ({
}, [enabled, isPressingButton]);
return (
-
+
-
+
);
};
diff --git a/components/CameraPage/Constants.ts b/components/CameraPage/Constants.ts
index 7c71892f..46544e70 100644
--- a/components/CameraPage/Constants.ts
+++ b/components/CameraPage/Constants.ts
@@ -1,30 +1,23 @@
import { Dimensions, Platform } from 'react-native';
-import StaticSafeAreaInsets from 'react-native-static-safe-area-insets';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
export const CONTENT_SPACING = 15;
-// Use zero insets on web, otherwise use StaticSafeAreaInsets
-const safeAreaInsets =
- Platform.OS === 'web'
- ? {
- safeAreaInsetsLeft: 0,
- safeAreaInsetsTop: 0,
- safeAreaInsetsRight: 0,
- safeAreaInsetsBottom: 0,
- }
- : StaticSafeAreaInsets;
-
-const SAFE_BOTTOM =
- Platform.select({
- ios: safeAreaInsets.safeAreaInsetsBottom,
- web: 0, // no insets on web
- }) ?? 0;
-
-export const SAFE_AREA_PADDING = {
- paddingLeft: safeAreaInsets.safeAreaInsetsLeft + CONTENT_SPACING,
- paddingTop: safeAreaInsets.safeAreaInsetsTop + CONTENT_SPACING,
- paddingRight: safeAreaInsets.safeAreaInsetsRight + CONTENT_SPACING,
- paddingBottom: SAFE_BOTTOM + CONTENT_SPACING,
+export const useSafeAreaPadding = () => {
+ const insets = useSafeAreaInsets();
+ const safeBottom =
+ Platform.select({
+ ios: insets.bottom,
+ web: 0, // no insets on web
+ default: insets.bottom,
+ }) ?? 0;
+
+ return {
+ paddingLeft: insets.left + CONTENT_SPACING,
+ paddingTop: insets.top + CONTENT_SPACING,
+ paddingRight: insets.right + CONTENT_SPACING,
+ paddingBottom: safeBottom + CONTENT_SPACING,
+ };
};
// The maximum zoom _factor_ you should be able to zoom in
@@ -34,8 +27,7 @@ export const SCREEN_WIDTH = Dimensions.get('window').width;
// For web, fall back to Dimensions.get("window").height
export const SCREEN_HEIGHT = Platform.select({
- android:
- Dimensions.get('screen').height - safeAreaInsets.safeAreaInsetsBottom,
+ android: Dimensions.get('screen').height,
ios: Dimensions.get('window').height,
web: Dimensions.get('window').height,
}) as number;
diff --git a/components/CameraPage/LiveButton.tsx b/components/CameraPage/LiveButton.tsx
index 5aa6ee59..5351bf07 100644
--- a/components/CameraPage/LiveButton.tsx
+++ b/components/CameraPage/LiveButton.tsx
@@ -6,7 +6,7 @@ import {
ActivityIndicator,
} from 'react-native';
import { useMutation } from '@tanstack/react-query';
-import { requestLivekitIngressMutation } from '@/lib/api/generated/@tanstack/react-query.gen';
+import { startLiveMutation } from '@/lib/api/generated/@tanstack/react-query.gen';
import { useToast } from '@/components/ToastUsage';
import { t } from '@/lib/i18n';
@@ -30,7 +30,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..3500d949 100644
--- a/components/CameraPage/LiveStream.tsx
+++ b/components/CameraPage/LiveStream.tsx
@@ -1,16 +1,14 @@
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,
useLocalParticipant,
VideoTrack,
- registerGlobals,
useTracks,
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,8 +16,12 @@ import { mediaDevices } from '@livekit/react-native-webrtc';
import useAuth from '@/hooks/useAuth';
import { BlurView } from 'expo-blur';
import { t } from '@/lib/i18n';
-
-registerGlobals();
+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';
interface LiveStreamProps {
token: string;
@@ -28,19 +30,41 @@ 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 [isUserLive, setIsUserLive] = useAtom(isUserLiveState);
+ const stopLive = useMutation({
+ ...stopLiveMutation(),
+ onSuccess: (data) => {
+ setIsUserLive(false);
+ },
+ });
+
+ useEffect(() => {
+ return () => {
+ stopLive.mutate({
+ query: {
+ room_name: roomName,
+ },
+ });
+ };
+ }, []);
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 +72,25 @@ export function LiveStream({ token, roomName, onDisconnect }: LiveStreamProps) {
}}
audio={true}
video={true}
- onDisconnected={handleDisconnect}
+ onDisconnected={() => {
+ console.log('onDisconnected');
+ setIsUserLive(false);
+ if (onDisconnect) {
+ onDisconnect();
+ }
+ }}
>
-
+
+ stopLive.mutate({
+ query: {
+ room_name: roomName,
+ },
+ })
+ }
+ />
);
@@ -59,15 +98,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 +267,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..e3167ed2 100644
--- a/components/CameraPage/index.tsx
+++ b/components/CameraPage/index.tsx
@@ -1,5 +1,12 @@
import * as React from 'react';
-import { useRef, useState, useCallback, useMemo } from 'react';
+import {
+ useRef,
+ useState,
+ useCallback,
+ useMemo,
+ RefObject,
+ useEffect,
+} from 'react';
import type { GestureResponderEvent } from 'react-native';
import { Text } from '../ui/text';
@@ -11,11 +18,7 @@ import {
TextInput,
KeyboardAvoidingView,
} from 'react-native';
-import type { PinchGestureHandlerGestureEvent } from 'react-native-gesture-handler';
-import {
- PinchGestureHandler,
- TapGestureHandler,
-} from 'react-native-gesture-handler';
+import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import type {
CameraProps,
CameraRuntimeError,
@@ -33,20 +36,19 @@ import {
CONTENT_SPACING,
CONTROL_BUTTON_SIZE,
MAX_ZOOM_FACTOR,
- SAFE_AREA_PADDING,
+ useSafeAreaPadding,
SCREEN_HEIGHT,
SCREEN_WIDTH,
} from './Constants';
import Reanimated, {
Extrapolate,
interpolate,
- useAnimatedGestureHandler,
useAnimatedProps,
useSharedValue,
useAnimatedStyle,
withTiming,
+ runOnJS,
} from 'react-native-reanimated';
-import { useEffect } from 'react';
import { useIsForeground } from '../../hooks/useIsForeground';
import { StatusBarBlurBackground } from './StatusBarBlurBackground';
import IonIcon from '@expo/vector-icons/Ionicons';
@@ -55,9 +57,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,11 +91,11 @@ 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;
-
+ const safePadding = useSafeAreaPadding();
const camera = useRef(null);
const [isCameraInitialized, setIsCameraInitialized] = useState(false);
const microphone = useMicrophonePermission();
@@ -182,7 +184,7 @@ export default function CameraPage(): React.ReactElement {
}, []);
const onMediaCaptured = useCallback(
(media: PhotoFile | VideoFile, type: 'photo' | 'video') => {
- router.replace({
+ router.navigate({
pathname: `/(camera)/mediapage`,
params: {
path: media.path,
@@ -229,30 +231,47 @@ export default function CameraPage(): React.ReactElement {
//#region Pinch to Zoom Gesture
// The gesture handler maps the linear pinch gesture (0 - 1) to an exponential curve since a camera's zoom
// function does not appear linear to the user. (aka zoom 0.1 -> 0.2 does not look equal in difference as 0.8 -> 0.9)
- const onPinchGesture = useAnimatedGestureHandler<
- PinchGestureHandlerGestureEvent,
- { startZoom?: number }
- >({
- onStart: (_, context) => {
- context.startZoom = zoom.value;
- },
- onActive: (event, context) => {
- // we're trying to map the scale gesture to a linear zoom here
- const startZoom = context.startZoom ?? 0;
- const scale = interpolate(
- event.scale,
- [1 - 1 / SCALE_FULL_ZOOM, 1, SCALE_FULL_ZOOM],
- [-1, 0, 1],
- Extrapolate.CLAMP,
- );
- zoom.value = interpolate(
- scale,
- [-1, 0, 1],
- [minZoom, startZoom, maxZoom],
- Extrapolate.CLAMP,
- );
- },
- });
+ const startZoom = useSharedValue(zoom.value);
+
+ const pinchGesture = useMemo(
+ () =>
+ Gesture.Pinch()
+ .enabled(isActive)
+ .onBegin(() => {
+ startZoom.value = zoom.value;
+ })
+ .onUpdate((event) => {
+ // we're trying to map the scale gesture to a linear zoom here
+ const scale = interpolate(
+ event.scale,
+ [1 - 1 / SCALE_FULL_ZOOM, 1, SCALE_FULL_ZOOM],
+ [-1, 0, 1],
+ Extrapolate.CLAMP,
+ );
+ zoom.value = interpolate(
+ scale,
+ [-1, 0, 1],
+ [minZoom, startZoom.value, maxZoom],
+ Extrapolate.CLAMP,
+ );
+ }),
+ [isActive, minZoom, maxZoom, zoom, startZoom],
+ );
+
+ const doubleTapGesture = useMemo(
+ () =>
+ Gesture.Tap()
+ .numberOfTaps(2)
+ .onEnd(() => {
+ runOnJS(onDoubleTap)();
+ }),
+ [onDoubleTap],
+ );
+
+ const composedGesture = useMemo(
+ () => Gesture.Simultaneous(pinchGesture, doubleTapGesture),
+ [pinchGesture, doubleTapGesture],
+ );
//#endregion
useEffect(() => {
@@ -268,7 +287,7 @@ export default function CameraPage(): React.ReactElement {
}, [location]);
useEffect(() => {
- let interval: NodeJS.Timeout;
+ let interval: ReturnType;
if (isRecording) {
interval = setInterval(() => {
setRecordingTime((prevTime) => prevTime + 1);
@@ -323,54 +342,52 @@ export default function CameraPage(): React.ReactElement {
return (
{device != null ? (
-
+
-
- console.log('Camera started!')}
- onStopped={() => console.log('Camera stopped!')}
- onPreviewStarted={() => console.log('Preview started!')}
- onPreviewStopped={() => console.log('Preview stopped!')}
- onOutputOrientationChanged={(o) =>
- console.log(`Output orientation changed to ${o}!`)
- }
- onPreviewOrientationChanged={(o) =>
- console.log(`Preview orientation changed to ${o}!`)
- }
- onUIRotationChanged={(degrees) =>
- console.log(`UI Rotation changed: ${degrees}ยฐ`)
- }
- format={format}
- fps={fps}
- photoHdr={photoHdr}
- videoHdr={videoHdr}
- photoQualityBalance="speed"
- lowLightBoost={device.supportsLowLightBoost && enableNightMode}
- enableZoomGesture={false}
- animatedProps={cameraAnimatedProps}
- exposure={0}
- enableFpsGraph={false}
- outputOrientation="device"
- photo={true}
- video={true}
- audio={microphone.hasPermission}
- enableLocation={location.hasPermission}
- />
-
+ console.log('Camera started!')}
+ onStopped={() => console.log('Camera stopped!')}
+ onPreviewStarted={() => console.log('Preview started!')}
+ onPreviewStopped={() => console.log('Preview stopped!')}
+ onOutputOrientationChanged={(o) =>
+ console.log(`Output orientation changed to ${o}!`)
+ }
+ onPreviewOrientationChanged={(o) =>
+ console.log(`Preview orientation changed to ${o}!`)
+ }
+ onUIRotationChanged={(degrees) =>
+ console.log(`UI Rotation changed: ${degrees}ยฐ`)
+ }
+ format={format}
+ fps={fps}
+ photoHdr={photoHdr}
+ videoHdr={videoHdr}
+ photoQualityBalance="speed"
+ lowLightBoost={device.supportsLowLightBoost && enableNightMode}
+ enableZoomGesture={false}
+ animatedProps={cameraAnimatedProps}
+ exposure={0}
+ enableFpsGraph={false}
+ outputOrientation="device"
+ photo={true}
+ video={true}
+ audio={microphone.hasPermission}
+ enableLocation={location.hasPermission}
+ />
{/* Add this new overlay component */}
-
+
) : (
Your phone does not have a Camera.
@@ -386,7 +403,14 @@ export default function CameraPage(): React.ReactElement {
{selectedMode === 'live' && (
)}
-
+
{shouldShowMediaTypeSwitch && (
{
+ // Replace so that when user disconnects from livestream page it no longer goes back to the camera page
router.replace({
- pathname: '/(tabs)/(home)/[feedId]/livestream',
+ pathname: '/(camera)/livestream',
params: {
feedId: feedId as string,
livekit_token: livekit_token,
@@ -469,7 +500,7 @@ export default function CameraPage(): React.ReactElement {
/>
) : selectedMode === 'photo' ? (
}
onMediaCaptured={onMediaCaptured}
flash={supportsFlash ? flash : 'off'}
enabled={isCameraInitialized && isActive}
@@ -477,7 +508,7 @@ export default function CameraPage(): React.ReactElement {
/>
) : (
}
onMediaCaptured={onMediaCaptured}
feedId={feedId as string}
flash={supportsFlash ? flash : 'off'}
@@ -493,6 +524,11 @@ export default function CameraPage(): React.ReactElement {
{
- toast.remove();
- router.navigate({
- pathname: '/(tabs)/(home)/[feedId]',
- params: {
- feedId: feedId as string,
- },
- });
+ if (router.canGoBack()) {
+ router.back();
+ } else {
+ router.navigate('/(tabs)/(home)');
+ return;
+ }
}}
>
@@ -557,7 +592,7 @@ const styles = StyleSheet.create({
captureButton: {
position: 'absolute',
alignSelf: 'center',
- bottom: SAFE_AREA_PADDING.paddingBottom,
+ bottom: 0,
},
captureButtonContainer: {
flexDirection: 'column',
@@ -581,8 +616,8 @@ const styles = StyleSheet.create({
},
rightButtonRow: {
position: 'absolute',
- right: SAFE_AREA_PADDING.paddingRight,
- top: SAFE_AREA_PADDING.paddingTop + 20 + 40,
+ right: 0,
+ top: 0,
},
text: {
color: 'white',
@@ -598,7 +633,7 @@ const styles = StyleSheet.create({
recordingTimer: {
position: 'absolute',
alignSelf: 'center',
- bottom: SAFE_AREA_PADDING.paddingBottom + 50,
+ bottom: 0,
},
recordingTimerText: {
color: 'white',
@@ -625,9 +660,9 @@ const styles = StyleSheet.create({
},
liveInputContainer: {
position: 'absolute',
- top: SAFE_AREA_PADDING.paddingTop + 20,
- left: SAFE_AREA_PADDING.paddingLeft,
- right: SAFE_AREA_PADDING.paddingRight,
+ top: 0,
+ left: 0,
+ right: 0,
zIndex: 1,
},
liveTextInput: {
diff --git a/components/Chat/chat-bottombar.tsx b/components/Chat/chat-bottombar.tsx
index a77cb09a..26517c90 100644
--- a/components/Chat/chat-bottombar.tsx
+++ b/components/Chat/chat-bottombar.tsx
@@ -5,9 +5,7 @@ import {
TouchableOpacity,
TextInput,
Text,
- useWindowDimensions,
StyleSheet,
- Platform,
useColorScheme,
} from 'react-native';
import { FileImage, Paperclip, Mic, ArrowUp } from '@/lib/icons';
@@ -26,20 +24,11 @@ const AnimatedTextInput = Animated.createAnimatedComponent(TextInput);
interface ChatBottombarProps {
sendMessage: (newMessage: string) => void;
- isMobile: boolean;
- onFocus: () => void;
- onBlur: () => void;
- canText?: boolean;
}
export const BottombarIcons = [{ icon: FileImage }, { icon: Paperclip }];
-export default function ChatBottombar({
- onFocus,
- canText,
- onBlur,
- sendMessage,
-}: ChatBottombarProps) {
+export default function ChatBottombar({ sendMessage }: ChatBottombarProps) {
const [sound, setSound] = useState(null);
const setMessage = useSetAtom(messageAtom);
const message = useAtomValue(messageAtom);
@@ -96,15 +85,17 @@ export default function ChatBottombar({
backgroundColor: inputBackground,
},
]}
+ autoCorrect={false}
+ autoCapitalize="none"
+ returnKeyType="default"
+ enablesReturnKeyAutomatically={true}
placeholder="แแแกแแฏแ"
placeholderTextColor={placeholderColor}
onFocus={() => {
setIsFocused(true);
- onFocus();
}}
onBlur={() => {
setIsFocused(false);
- onBlur();
}}
onContentSizeChange={(event) => {
const newHeight =
@@ -119,7 +110,7 @@ export default function ChatBottombar({
}}
/>
-
+
@@ -127,10 +118,8 @@ export default function ChatBottombar({
}
export function SendButton({
- canText,
sendMessage,
}: {
- canText: boolean;
sendMessage: (message: string) => void;
}) {
const message = useAtomValue(messageAtom);
@@ -151,12 +140,11 @@ export function SendButton({
return (
diff --git a/components/Chat/chat-list.tsx b/components/Chat/chat-list.tsx
index 61461b95..f2d5c703 100644
--- a/components/Chat/chat-list.tsx
+++ b/components/Chat/chat-list.tsx
@@ -9,34 +9,27 @@ import React, {
import {
View,
ScrollView,
- Keyboard,
LayoutChangeEvent,
- FlatList,
- LayoutAnimation,
Platform,
UIManager,
InteractionManager,
} from 'react-native';
import ChatBottombar from './chat-bottombar';
-import { useQueryClient } from '@tanstack/react-query';
import { User, ChatMessage } from '@/lib/api/generated';
import useAuth from '@/hooks/useAuth';
import { SocketContext } from './socket/context';
import useMessageUpdates from './useMessageUpdates';
import useMessageFetching from './useMessageFetching';
-import { format } from 'date-fns';
-import Sentry from '@sentry/react-native';
+import * as Sentry from '@sentry/react-native';
import { useKeyboardHandler } from 'react-native-keyboard-controller';
require('dayjs/locale/ka');
interface ChatListProps {
selectedUser: User;
- isMobile: boolean;
- canText?: boolean;
}
import { useAtomValue, useSetAtom } from 'jotai';
import { isChatUserOnlineState, messageAtom } from '@/lib/state/chat';
-import { useLocalSearchParams } from 'expo-router';
+import { useGlobalSearchParams, useLocalSearchParams } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import SentMediaItem from '../SentMediaItem';
import useMessageRoom from '@/hooks/useMessageRoom';
@@ -65,31 +58,18 @@ if (Platform.OS === 'android') {
}
}
-export function ChatList({ selectedUser, isMobile, canText }: ChatListProps) {
- const messagesContainerRef = useRef(null);
+export function ChatList({ selectedUser }: ChatListProps) {
const trackedMessageIdsRef = useRef>(new Set());
- const params = useLocalSearchParams<{
+ const params = useGlobalSearchParams<{
roomId: string;
}>();
const { user } = useAuth();
const socketContext = useContext(SocketContext);
- const scrolledFirstTime = useRef(false);
- const [refetchInterval, setRefetchInterval] = useState(0);
const { room, isFetching } = useMessageRoom(params.roomId, false);
- const {
- orderedPages,
- fetchNextPage,
- hasNextPage,
- isFetchingNextPage,
- firstPage,
- } = useMessageFetching(
- params.roomId,
- refetchInterval,
- false,
- selectedUser.id,
- );
+ const { orderedPages, fetchNextPage, hasNextPage, isFetchingNextPage } =
+ useMessageFetching(params.roomId);
const setIsChatUserOnline = useSetAtom(isChatUserOnlineState);
const setMessage = useSetAtom(messageAtom);
const { sendMessageIdsToBackend, addMessageToCache } = useMessageUpdates(
@@ -113,23 +93,6 @@ export function ChatList({ selectedUser, isMobile, canText }: ChatListProps) {
};
}, [selectedUser.id, socketContext]);
- useEffect(() => {
- if (messagesContainerRef.current) {
- const lastMessage = firstPage?.messages[firstPage.messages.length - 1];
- if (!lastMessage) {
- return;
- }
-
- if (!scrolledFirstTime.current) {
- messagesContainerRef.current.scrollToEnd({ animated: false });
- scrolledFirstTime.current = true;
- return;
- }
-
- messagesContainerRef.current.scrollToEnd({ animated: true });
- }
- }, [firstPage?.messages.length]);
-
useEffect(() => {
orderedPages.forEach((page) => {
page.messages.forEach((item: ChatMessage, messageIndex: number) => {
@@ -168,11 +131,12 @@ export function ChatList({ selectedUser, isMobile, canText }: ChatListProps) {
}
// Memoize the converted messages to prevent recreation on every render
- const convertedMessagesForGiftedChat = useMemo(
+ const messages = useMemo(
() =>
orderedPages.map((page, pageIndex) =>
page.messages.map((message, messageIndex) => ({
_id: message.id || message.temporary_id,
+ // @ts-ignore
text: message.message,
createdAt:
message.id && !message.temporary_id
@@ -185,10 +149,7 @@ export function ChatList({ selectedUser, isMobile, canText }: ChatListProps) {
);
// Memoize the messageItems array to prevent recreation on every render
- const messageItems = useMemo(
- () => [...convertedMessagesForGiftedChat.flat()],
- [convertedMessagesForGiftedChat],
- );
+ const messageItems = useMemo(() => [...messages.flat()], [messages]);
// Memoize the renderItem function to prevent recreation on every render
const messageRenderItem = useCallback(
@@ -238,6 +199,7 @@ export function ChatList({ selectedUser, isMobile, canText }: ChatListProps) {
id: randomTemporaryMessageId,
temporary_id: randomTemporaryMessageId,
author_id: user.id,
+ // @ts-ignore
message: messageToSend,
room_id: params.roomId,
message_state: 'SENT',
@@ -385,9 +347,9 @@ export function ChatList({ selectedUser, isMobile, canText }: ChatListProps) {
},
[layoutHeight, isAtBottom, isAtTop, hasScrolled, setHasScrolled],
);
-
+ const insets = useSafeAreaInsets();
const bottomOffset = isWeb ? 0 : 0;
- const keyboardOffsetValue = isIOS ? 80 : 50;
+ const keyboardOffsetValue = insets.bottom;
const keyboardHeight = useSharedValue(0);
const keyboardIsOpening = useSharedValue(false);
@@ -478,7 +440,7 @@ export function ChatList({ selectedUser, isMobile, canText }: ChatListProps) {
disableVirtualization={true}
onContentSizeChange={onContentSizeChange}
onLayout={onListLayout}
- keyExtractor={(item) => item._id.toString()}
+ keyExtractor={(item) => item._id}
initialNumToRender={isNative ? 32 : 62}
maxToRenderPerBatch={isNative ? 32 : 62}
keyboardDismissMode="on-drag"
@@ -501,13 +463,7 @@ export function ChatList({ selectedUser, isMobile, canText }: ChatListProps) {
/>
- {}}
- onBlur={() => {}}
- canText={canText}
- sendMessage={onSendMessage}
- />
+
);
diff --git a/components/Chat/chat-topbar.tsx b/components/Chat/chat-topbar.tsx
index 439fe4e4..606302cc 100644
--- a/components/Chat/chat-topbar.tsx
+++ b/components/Chat/chat-topbar.tsx
@@ -25,11 +25,11 @@ import useMessageRoom from '@/hooks/useMessageRoom';
import useAuth from '@/hooks/useAuth';
import usePokeLiveUser from '@/hooks/usePokeUser';
import { User } from 'lucide-react-native';
+import useDeleteFriendMutation from '@/hooks/useDeleteFriendMutation';
export default function ChatTopbar() {
- const { roomId, feedId } = useGlobalSearchParams<{
+ const { roomId } = useGlobalSearchParams<{
roomId: string;
- feedId: string;
}>();
const { room, isFetching } = useMessageRoom(roomId);
const { user } = useAuth();
@@ -39,6 +39,16 @@ export default function ChatTopbar() {
isChatUserOnlineState,
);
+ const deleteFriendMutation = useDeleteFriendMutation();
+
+ const handleDeleteFriend = () => {
+ deleteFriendMutation.mutate({
+ path: {
+ friend_id: selectedUser?.id || '',
+ },
+ });
+ };
+
const selectedUser = room?.participants.find((p) => p.id !== user.id);
const userPhoto = selectedUser?.photos[0]?.image_url[0];
@@ -97,15 +107,25 @@ export default function ChatTopbar() {
title: 'แฃแฏแแแ',
imageColor: theme.colors.primary,
},
- {
- id: 'addFriend',
- title: 'แแแแแแ แแ แแแแแขแแแ',
- image: Platform.select({
- ios: 'person.badge.plus',
- android: 'ic_menu_add_gray',
- }),
- imageColor: theme.colors.primary,
- },
+ !room?.is_friend
+ ? {
+ id: 'addFriend',
+ title: 'แแแแแแ แแ แแแแแขแแแ',
+ image: Platform.select({
+ ios: 'person.badge.plus',
+ android: 'ic_menu_add_gray',
+ }),
+ imageColor: theme.colors.primary,
+ }
+ : {
+ id: 'deleteFriend',
+ title: 'แแแแแแ แแก แฌแแจแแ',
+ image: Platform.select({
+ ios: 'person.badge.minus',
+ android: 'ic_menu_add_gray',
+ }),
+ imageColor: theme.colors.primary,
+ },
];
return (
@@ -138,9 +158,8 @@ export default function ChatTopbar() {
onPress={() => {
if (userPhoto) {
router.navigate({
- pathname: '/(tabs)/(home)/profile-picture',
+ pathname: '/(chat)/[roomId]/profile-picture',
params: {
- feedId: feedId,
roomId: roomId,
imageUrl: userPhoto,
},
@@ -214,6 +233,8 @@ export default function ChatTopbar() {
handleAddFriend();
} else if (nativeEvent.event === 'poke') {
handlePoke();
+ } else if (nativeEvent.event === 'deleteFriend') {
+ handleDeleteFriend();
}
});
}}
@@ -291,7 +312,6 @@ const styles = StyleSheet.create({
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 3.84,
- elevation: 5,
},
usernameContainer: {
flexDirection: 'column',
diff --git a/components/Chat/index.tsx b/components/Chat/index.tsx
deleted file mode 100644
index 18da1738..00000000
--- a/components/Chat/index.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import { ChatList } from './chat-list';
-import React, { useEffect, useState } from 'react';
-import { User, ChatMessage } from '@/lib/api/generated';
-
-interface ChatProps {
- messages?: any[];
- selectedUser: User;
- isMobile: boolean;
- canText?: boolean;
-}
-
-export default function Chat({ selectedUser, isMobile, canText }: ChatProps) {
- return (
-
- );
-}
diff --git a/components/Chat/message-item-text.tsx b/components/Chat/message-item-text.tsx
index 24b4adfc..e69de29b 100644
--- a/components/Chat/message-item-text.tsx
+++ b/components/Chat/message-item-text.tsx
@@ -1,50 +0,0 @@
-import { StyleSheet, Text, useColorScheme } from 'react-native';
-
-export default function MessageItemText({
- text,
- isAuthor = false,
-}: {
- text: string;
- isAuthor?: boolean;
-}) {
- const colorScheme = useColorScheme();
- const isDark = colorScheme === 'dark';
-
- return (
-
- {text}
-
- );
-}
-
-const styles = StyleSheet.create({
- text: {
- padding: 0,
- fontSize: 16,
- },
- // Dark mode text styles
- authorTextDark: {
- color: '#FFFFFF', // White text for dark mode author messages
- },
- nonAuthorTextDark: {
- color: '#FFFFFF', // White text for dark mode non-author messages
- },
- // Light mode text styles
- authorTextLight: {
- color: '#FFFFFF', // White text for light mode author messages
- },
- nonAuthorTextLight: {
- color: '#000000', // Black text for light mode non-author messages (Messenger/Signal style)
- },
-});
diff --git a/components/Chat/socket/MessageConnectionWrapper.tsx b/components/Chat/socket/MessageConnectionWrapper.tsx
index 100c9c4c..eebb19b7 100644
--- a/components/Chat/socket/MessageConnectionWrapper.tsx
+++ b/components/Chat/socket/MessageConnectionWrapper.tsx
@@ -7,12 +7,11 @@ import React, {
} from 'react';
import { getSocket } from './socket';
import { SocketContext } from './context';
-import { ChatMessage } from '@/lib/api/generated';
+import { ChatMessage, GetUserChatRoomsResponse } from '@/lib/api/generated';
import { useSetAtom } from 'jotai';
import { isChatUserOnlineState } from '@/lib/state/chat';
-import useAuth from '@/hooks/useAuth';
import { useGlobalSearchParams, useLocalSearchParams } from 'expo-router';
-import Sentry from '@sentry/react-native';
+import * as Sentry from '@sentry/react-native';
// Create a context for the socket
export function useSocket() {
@@ -20,40 +19,69 @@ export function useSocket() {
}
import { useQueryClient } from '@tanstack/react-query';
import ProtocolService from '@/lib/services/ProtocolService';
+import { getDeviceId } from '@/lib/device-id';
+import useAuth from '@/hooks/useAuth';
import { useIsFocused } from '@react-navigation/native';
+import { AppState } from 'react-native';
import {
getMessagesChatMessagesGetInfiniteOptions,
getMessagesChatMessagesGetInfiniteQueryKey,
+ getUserChatRoomsOptions,
} from '@/lib/api/generated/@tanstack/react-query.gen';
+import { CHAT_PAGE_SIZE } from '@/lib/utils';
+import { Toast, useToast } from '@/components/ToastUsage';
+import { useMessageSpamPrevention } from '@/hooks/useMessageSpamPrevention';
+import { t } from '@/lib/i18n';
export default function MessageConnectionWrapper({
+ deviceId,
children,
publicKey,
+ showMessagePreview,
}: {
+ deviceId: string;
children: React.ReactNode;
publicKey: string;
+ showMessagePreview: boolean;
}) {
const [isConnected, setIsConnected] = useState(false);
- const { user } = useAuth();
+ const { user, logout } = useAuth();
+ const { error: errorToast } = useToast();
const { roomId } = useGlobalSearchParams<{ roomId: string }>();
const queryClient = useQueryClient();
- const socketRef = useRef(getSocket(user.id, publicKey));
+ const socketRef = useRef(getSocket(user.id, publicKey, deviceId));
const setIsChatUserOnline = useSetAtom(isChatUserOnlineState);
const isFocused = useIsFocused();
- const pageSize = 15;
+ const appStateRef = useRef(AppState.currentState);
+ const { canShowMessagePreview, recordMessage, getSenderTimeout } =
+ useMessageSpamPrevention({
+ timeoutMs: 5000, // 5 seconds timeout
+ maxMessages: 3, // Max 3 messages in 5 seconds
+ });
const messageOptions = getMessagesChatMessagesGetInfiniteOptions({
query: {
- page_size: pageSize,
+ page_size: CHAT_PAGE_SIZE,
room_id: roomId,
},
});
useEffect(() => {
- if (isFocused) {
- socketRef.current.connect();
- } else {
+ const subscription = AppState.addEventListener('change', (nextAppState) => {
+ appStateRef.current = nextAppState;
+ const shouldConnect = isFocused && nextAppState === 'active';
+ if (shouldConnect) {
+ socketRef.current.connect();
+ } else {
+ socketRef.current.disconnect();
+ }
+ });
+
+ socketRef.current.connect();
+
+ return () => {
+ subscription.remove();
socketRef.current.disconnect();
- }
+ };
}, [isFocused]);
useEffect(() => {
@@ -130,8 +158,11 @@ export default function MessageConnectionWrapper({
encrypted_content: string;
nonce: string;
sender: string;
+ sender_profile_picture: string;
+ sender_username: string;
id: string;
temporary_id: string;
+ room_id: string;
}) => {
const addIncomingMessage = async (newMessage: {
encrypted_content: string;
@@ -139,6 +170,7 @@ export default function MessageConnectionWrapper({
sender: string;
id: string;
temporary_id: string;
+ room_id: string;
}) => {
let decryptedMessage = '';
try {
@@ -161,9 +193,66 @@ export default function MessageConnectionWrapper({
if (!decryptedMessage) {
return;
}
+ console.log(showMessagePreview);
+ // Show message preview if enabled and user is not focused on chat
+ if (showMessagePreview) {
+ // Check spam prevention
+ if (canShowMessagePreview(newMessage.sender)) {
+ console.log(roomId, privateMessage.room_id);
+ if (roomId === privateMessage.room_id) {
+ return;
+ }
+ Toast.message({
+ message: decryptedMessage,
+ senderUsername: privateMessage.sender_username,
+ senderProfilePicture: privateMessage.sender_profile_picture,
+ senderId: newMessage.sender,
+ roomId: newMessage.room_id || '',
+ duration: 5000,
+ });
+ const queryOptions = getUserChatRoomsOptions();
+ const hasQueryData = queryClient.getQueryData(
+ queryOptions.queryKey,
+ );
+
+ if (!hasQueryData) {
+ await queryClient.invalidateQueries({
+ queryKey: queryOptions.queryKey,
+ });
+ }
+ queryClient.setQueryData(
+ queryOptions.queryKey,
+ // @ts-ignore
+ (oldData: GetUserChatRoomsResponse['chat_rooms']) => {
+ if (!oldData) return oldData;
+ return oldData.map((chat) =>
+ chat.id === newMessage.room_id
+ ? {
+ ...chat,
+ last_message: {
+ ...chat.last_message,
+ sent_date: new Date().toISOString(),
+ message: decryptedMessage,
+ },
+ }
+ : chat,
+ );
+ },
+ );
+ recordMessage(newMessage.sender);
+ } else {
+ // Optional: Show a different toast indicating spam prevention is active
+ const timeout = getSenderTimeout(newMessage.sender);
+ if (timeout > 0) {
+ console.log(
+ `Message preview blocked for sender ${newMessage.sender} due to spam prevention. Timeout: ${timeout}ms`,
+ );
+ }
+ }
+ }
+
queryClient.setQueryData(messageOptions.queryKey, (oldData) => {
if (!oldData) return oldData;
-
const updatedPages = oldData.pages.map((page) => {
if (page.page === 1) {
return {
@@ -194,27 +283,55 @@ export default function MessageConnectionWrapper({
});
};
- addIncomingMessage(privateMessage as any);
+ addIncomingMessage(privateMessage);
+ };
+
+ const handleForceLogout = async () => {
+ console.log('handleForceLogout');
+ try {
+ await ProtocolService.clearKeys();
+ } catch {}
+ try {
+ await logout();
+ } catch {}
+
+ errorToast({
+ title: t('common.forced_logout'),
+ description: t('common.forced_logout_description'),
+ });
};
socket.on('user_connection_status', handleConnectionStatus);
socket.on('user_public_key', handlePublicKey);
socket.on('private_message', handlePrivateMessage);
socket.on('notify_single_message_seen', handleMessageSeen);
+ socket.on('force_logout', handleForceLogout);
socket.on('connect', onConnect);
socket.on('disconnect', onDisconnect);
+ socket.on('error', onError);
socket.on('connect_error', onError);
return () => {
- socket.off('connect', onConnect);
- socket.off('private_message', handlePrivateMessage);
- socket.off('disconnect', onDisconnect);
- socket.off('connect_error', onError);
- socket.off('user_connection_status', handleConnectionStatus);
- socket.off('user_public_key', handlePublicKey);
- socket.off('notify_single_message_seen', handleMessageSeen);
+ if (!socketRef.current) return;
+ socketRef.current.off('connect', onConnect);
+ socketRef.current.off('private_message', handlePrivateMessage);
+ socketRef.current.off('disconnect', onDisconnect);
+ socketRef.current.off('connect_error', onError);
+ socketRef.current.off('user_connection_status', handleConnectionStatus);
+ socketRef.current.off('user_public_key', handlePublicKey);
+ socketRef.current.off('notify_single_message_seen', handleMessageSeen);
+ socketRef.current.off('force_logout', handleForceLogout);
+ socketRef.current.off('error', onError);
};
- }, [queryClient, roomId]);
+ }, [
+ queryClient,
+ roomId,
+ showMessagePreview,
+ isFocused,
+ canShowMessagePreview,
+ recordMessage,
+ getSenderTimeout,
+ ]);
return (
diff --git a/components/Chat/socket/socket.ts b/components/Chat/socket/socket.ts
index 54737a07..6cae7236 100644
--- a/components/Chat/socket/socket.ts
+++ b/components/Chat/socket/socket.ts
@@ -2,7 +2,11 @@ import { io } from 'socket.io-client';
import { API_BASE_URL } from '@/lib/api/config';
// "undefined" means the URL will be computed from the `window.location` object
-export const getSocket = (userId: string, publicKey: string) => {
+export const getSocket = (
+ userId: string,
+ publicKey: string,
+ deviceId: string,
+) => {
return io(API_BASE_URL, {
autoConnect: false,
reconnection: true,
@@ -11,6 +15,7 @@ export const getSocket = (userId: string, publicKey: string) => {
auth: {
userId: userId,
publicKey: publicKey,
+ deviceId: deviceId,
},
transports: ['websocket'],
});
diff --git a/components/Chat/useMessageFetching.ts b/components/Chat/useMessageFetching.ts
index 1a8593c5..b45a6798 100644
--- a/components/Chat/useMessageFetching.ts
+++ b/components/Chat/useMessageFetching.ts
@@ -1,21 +1,9 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import useAuth from '@/hooks/useAuth';
-import {
- getMessagesChatMessagesGetInfiniteOptions,
- getMessagesChatMessagesGetOptions,
-} from '@/lib/api/generated/@tanstack/react-query.gen';
-import { getMessagesChatMessagesGet } from '@/lib/api/generated';
-import { ChatMessage, GetMessagesResponse } from '@/lib/api/generated';
-import ProtocolService from '@/lib/services/ProtocolService';
+import { getMessagesChatMessagesGetInfiniteOptions } from '@/lib/api/generated/@tanstack/react-query.gen';
+import { decryptMessages } from '@/lib/utils';
-type ChatMessages = ChatMessage[];
-
-const useMessageFetching = (
- roomId: string,
- refetchInterval: number | undefined,
- idle: boolean,
- recipientId: string,
-) => {
+const useMessageFetching = (roomId: string) => {
const { user } = useAuth();
const pageSize = 15;
const queryOptions = getMessagesChatMessagesGetInfiniteOptions({
@@ -27,84 +15,8 @@ const useMessageFetching = (
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
...queryOptions,
- queryFn: async ({ pageParam, queryKey, signal }) => {
- const one = queryKey[0];
-
- const { data } = await getMessagesChatMessagesGet({
- query: {
- page_size: one.query.page_size,
- room_id: one.query.room_id,
- page: pageParam as number,
- },
- signal,
- throwOnError: true,
- });
-
- // Decrypt messages
- const localUserId = user?.id;
-
- if (!localUserId) {
- return data;
- }
-
- let decryptedMessages: ChatMessages = [];
- try {
- const processedMessages = await Promise.all(
- data.messages.map(async (message: ChatMessage) => {
- try {
- if (message.encrypted_content && message.nonce) {
- let decryptedMessage = '';
-
- decryptedMessage = await ProtocolService.decryptMessage(
- localUserId === message.author_id
- ? message.recipient_id
- : message.author_id,
- {
- encryptedMessage: message.encrypted_content,
- nonce: message.nonce,
- },
- );
-
- return {
- ...message,
- message: decryptedMessage,
- };
- }
- return message;
- } catch (decryptError) {
- // console.error(
- // `Failed to decrypt message ${message.id}:`,
- // decryptError
- // );
- return null; // Return null for failed messages
- }
- }),
- );
-
- // Filter out null values from failed decryption attempts
- decryptedMessages = processedMessages.filter(
- (msg): msg is ChatMessage => msg !== null,
- );
- } catch (error) {
- console.error('Error processing messages', error);
- decryptedMessages = data.messages
- .map((message: ChatMessage) => {
- if (message.encrypted_content) {
- return null;
- }
- return message;
- })
- .filter((msg): msg is ChatMessage => msg !== null);
- }
-
- // Return the same structure as GetMessagesResponse but with decrypted messages
- const response: GetMessagesResponse = {
- ...data,
- messages: decryptedMessages,
- };
-
- return response;
- },
+ queryFn: decryptMessages(user),
+ // staleTime: Infinity,
getNextPageParam: (lastPage, allPages) => {
if (!lastPage.next_cursor) {
return undefined;
@@ -119,10 +31,9 @@ const useMessageFetching = (
getPreviousPageParam: (firstPage) =>
firstPage.previous_cursor || undefined,
refetchOnMount: true,
- staleTime: 1000 * 30, // Consider data fresh for 30 seconds
- gcTime: 1000 * 60 * 5, // Keep data in cache for 5 minutes
- refetchInterval: idle ? false : refetchInterval,
- refetchOnWindowFocus: !idle,
+ // staleTime: 1000 * 5, // Consider data fresh for 30 seconds
+ // gcTime: 1000 * 60 * 5, // Keep data in cache for 5 minutes
+ refetchOnWindowFocus: true,
refetchIntervalInBackground: false,
placeholderData: {
pages: [
@@ -135,14 +46,12 @@ const useMessageFetching = (
pageParams: [],
},
});
- const firstPage = data?.pages.find((item) => item.page === 1);
- let orderedPages = data?.pages.sort((a, b) => a.page - b.page) || [];
+ let orderedPages = data?.pages.sort((a, b) => b.page - a.page) || [];
return {
fetchNextPage,
hasNextPage,
isFetchingNextPage,
- firstPage,
orderedPages,
};
};
diff --git a/components/Chat/useMessageUpdates.ts b/components/Chat/useMessageUpdates.ts
index 4d686698..a5e95586 100644
--- a/components/Chat/useMessageUpdates.ts
+++ b/components/Chat/useMessageUpdates.ts
@@ -4,6 +4,7 @@ import {
getMessagesChatMessagesGetInfiniteOptions,
} from '@/lib/api/generated/@tanstack/react-query.gen';
import { ChatMessage } from '@/lib/api/generated';
+import { CHAT_PAGE_SIZE } from '@/lib/utils';
const useMessageUpdates = (
roomId: string,
@@ -13,10 +14,10 @@ const useMessageUpdates = (
const mutateUpdateMessages = useMutation({
...updateMessageStateChatUpdateMessagesPostMutation(),
});
- const pageSize = 15;
+
const messageOptions = getMessagesChatMessagesGetInfiniteOptions({
query: {
- page_size: pageSize,
+ page_size: CHAT_PAGE_SIZE,
room_id: roomId,
},
});
@@ -27,7 +28,10 @@ const useMessageUpdates = (
(mutateUpdateMessages.mutate as any)(
{
body: {
- messages: messageIds.map((item) => ({ id: item, state: 'READ' })),
+ // TODO: investigate why messageIds has undefined on message send
+ messages: messageIds
+ .filter(Boolean)
+ .map((item) => ({ id: item, state: 'READ' })),
},
},
{
diff --git a/components/ChatFriendsStories/index.tsx b/components/ChatFriendsStories/index.tsx
new file mode 100644
index 00000000..5db57db6
--- /dev/null
+++ b/components/ChatFriendsStories/index.tsx
@@ -0,0 +1,127 @@
+import React from 'react';
+import { View, FlatList, TouchableOpacity, StyleSheet } from 'react-native';
+import { useQuery } from '@tanstack/react-query';
+import { getFriendsListOptions } from '@/lib/api/generated/@tanstack/react-query.gen';
+import { Text } from '@/components/ui/text';
+import UserAvatarLayout from '@/components/UserAvatar';
+import { useTheme } from '@/lib/theme';
+import { AvatarImage } from '../ui/avatar';
+import { t } from '@/lib/i18n';
+import useLiveUser from '@/hooks/useLiveUser';
+import FriendRequests from '../ContactSyncSheet/FriendRequests';
+
+export default function ChatFriendsStories() {
+ const theme = useTheme();
+ const { data: friends = [] } = useQuery({
+ ...getFriendsListOptions(),
+ staleTime: 1000 * 30,
+ });
+ const { joinChat } = useLiveUser();
+ const handlePress = (friendId: string) => {
+ joinChat.mutate({ targetUserId: friendId });
+ };
+ if (!friends || friends.length === 0) {
+ return (
+
+
+
+ );
+ }
+ return (
+
+
+
+
+
+ {t('common.friends')}
+
+ item.id}
+ contentContainerStyle={styles.listContent}
+ renderItem={({ item }) => {
+ const imageUrl = item.photos?.[0]?.image_url?.[0] || '';
+ return (
+ handlePress(item.id)}
+ >
+
+
+ {imageUrl ? (
+
+ ) : (
+
+ {item.username?.charAt(0)?.toUpperCase() || ''}
+
+ )}
+
+
+
+ {item.username || ''}
+
+
+ );
+ }}
+ />
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ paddingVertical: 8,
+ marginBottom: 15,
+ },
+ sectionTitle: {
+ fontSize: 22,
+ fontWeight: '700',
+ marginBottom: 12,
+ marginLeft: 16,
+ letterSpacing: -0.4,
+ },
+ listContent: {
+ paddingHorizontal: 8,
+ },
+ storyItem: {
+ width: 72,
+ marginRight: 12,
+ alignItems: 'center',
+ },
+ avatarInner: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ width: '100%',
+ height: '100%',
+ },
+ avatarImage: {
+ borderRadius: 9999,
+ },
+ initials: {
+ fontSize: 20,
+ fontWeight: '600',
+ },
+ username: {
+ marginTop: 6,
+ fontSize: 12,
+ },
+});
diff --git a/components/ChatItem/index.tsx b/components/ChatItem/index.tsx
index 7ae96bf1..fa82b59c 100644
--- a/components/ChatItem/index.tsx
+++ b/components/ChatItem/index.tsx
@@ -1,4 +1,3 @@
-// @ts-nocheck
import React, { useCallback } from 'react';
import { View, TouchableHighlight, StyleSheet } from 'react-native';
import { Link } from 'expo-router';
@@ -6,7 +5,7 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import useAuth from '@/hooks/useAuth';
import { Text } from '../ui/text';
import { ChatRoom } from '@/lib/api/generated';
-import { CheckCircle2 } from 'lucide-react-native';
+import { Ionicons } from '@expo/vector-icons';
import { FontSizes } from '@/lib/theme';
import { useQueryClient } from '@tanstack/react-query';
import { useTheme } from '@/lib/theme';
@@ -19,7 +18,6 @@ function ChatItem({ item }: { item: ChatRoom }) {
const targetUser = item.participants.find(
(user) => user.id !== authorizedUser.id,
);
-
const isNavigationEnabled = !!targetUser?.username;
// Prefetch chat data when user interacts with chat item
@@ -82,7 +80,8 @@ function ChatItem({ item }: { item: ChatRoom }) {
return null;
}
return (
-
@@ -101,11 +101,10 @@ function ChatItem({ item }: { item: ChatRoom }) {
}
return null;
};
-
return (
-
+
{targetUser?.username || '[deleted]'}
- {formatDate(new Date(item.last_message?.sent_date))}
+ {formatDate(new Date(item.last_message?.sent_date || ''))}
@@ -159,7 +163,8 @@ function ChatItem({ item }: { item: ChatRoom }) {
]}
numberOfLines={1}
>
- {item.last_message?.message}
+ {/* @ts-ignore */}
+ {item.last_message?.message || ''}
{renderMessageStatus()}
@@ -196,8 +201,8 @@ const styles = StyleSheet.create({
flex: 1,
},
avatarContainer: {
- paddingTop: 6,
- paddingBottom: 12,
+ paddingTop: 0,
+ paddingBottom: 15,
paddingLeft: 12,
},
avatar: {
diff --git a/components/ChatRoomList/index.tsx b/components/ChatRoomList/index.tsx
index 615b3aa1..409557a6 100644
--- a/components/ChatRoomList/index.tsx
+++ b/components/ChatRoomList/index.tsx
@@ -1,57 +1,71 @@
import React, { useCallback, useEffect, useRef } from 'react';
-import {
- ScrollView,
- View,
- RefreshControl,
- ActivityIndicator,
- StyleSheet,
-} from 'react-native';
+import { ScrollView, View, RefreshControl, StyleSheet } from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+import { Text } from '@/components/ui/text';
+import { useTheme } from '@/lib/theme';
import { useQueryClient } from '@tanstack/react-query';
import useUserChats from '@/hooks/useUserChats';
import ChatItem from '../ChatItem';
import useAuth from '@/hooks/useAuth';
+import {
+ getFriendRequestsQueryKey,
+ getFriendsListInfiniteQueryKey,
+ getFriendsListQueryKey,
+ getMessageChatRoomOptions,
+ getMessagesChatMessagesGetInfiniteOptions,
+ getUserChatRoomsOptions,
+ getUserChatRoomsQueryKey,
+} from '@/lib/api/generated/@tanstack/react-query.gen';
+import { CHAT_PAGE_SIZE, decryptMessages } from '@/lib/utils';
+import { ChatRoom } from '@/lib/api/generated';
+import { t } from '@/lib/i18n';
-export default function ChatRoomList() {
+export default function ChatRoomList({ header }: { header?: React.ReactNode }) {
+ const theme = useTheme();
const queryClient = useQueryClient();
const { user } = useAuth();
+ const queryOptions = getUserChatRoomsOptions();
+ const friendsQueryKey = getFriendsListQueryKey();
+ const friendsRequests = getFriendRequestsQueryKey();
const { chats, isFetching, refetch } = useUserChats({ poolMs: 5000 });
const prefetchedChatsRef = useRef(new Set());
-
const onRefresh = useCallback(() => {
queryClient.invalidateQueries({
- queryKey: ['user-chat-rooms'],
+ queryKey: queryOptions.queryKey,
+ });
+ queryClient.invalidateQueries({
+ queryKey: friendsQueryKey,
+ });
+ queryClient.invalidateQueries({
+ queryKey: friendsRequests,
});
refetch();
}, [queryClient, refetch]);
// Prefetch messages for each chat room when chat list is loaded
useEffect(() => {
- if (chats && chats.chat_rooms.length > 0) {
+ if (chats && chats.length > 0) {
// Prefetch the first few chat rooms' messages
- chats.chat_rooms.slice(0, 3).forEach((chat) => {
+ chats.slice(0, 3).forEach((chat) => {
// Skip if already prefetched
if (prefetchedChatsRef.current.has(chat.id)) {
return;
}
- const recipientId =
- chat.participants.find((p) => p.id !== user.id)?.id || '';
-
- // // Prefetch message room data
- // queryClient.prefetchQuery({
- // queryKey: ["user-chat-room", chat.id],
- // queryFn: () => api.getMessageRoom(chat.id),
- // staleTime: 60 * 1000, // 1 minute
- // });
+ const messageOptions = getMessagesChatMessagesGetInfiniteOptions({
+ query: {
+ page_size: CHAT_PAGE_SIZE,
+ room_id: chat.id,
+ },
+ });
- // // Prefetch first page of messages
- // queryClient.prefetchInfiniteQuery({
- // queryKey: ["messages", chat.id],
- // queryFn: ({ pageParam = 1 }) =>
- // api.fetchMessages(pageParam, 30, chat.id, user.id),
- // initialPageParam: 1,
- // });
+ // Prefetch first page of messages
+ queryClient.prefetchInfiniteQuery({
+ ...messageOptions,
+ queryFn: decryptMessages(user),
+ initialPageParam: 1,
+ });
// Mark as prefetched
prefetchedChatsRef.current.add(chat.id);
@@ -60,11 +74,10 @@ export default function ChatRoomList() {
}, [chats, queryClient, user.id]);
function renderList() {
- return chats?.chat_rooms.map((item) => (
-
+ return chats?.map((item) => (
+
));
}
-
return (
}
>
- {isFetching ? (
-
- ) : (
+ {isFetching ? null : (
<>
+ {header}
{renderList()}
- {!isFetching && !chats?.chat_rooms.length && (
-
+ {!isFetching && !chats?.length && (
+
+
+
+ {t('common.no_chats_yet')}
+
+
+ {t('common.add_friends_from_stories')}
+
+
)}
>
)}
@@ -91,7 +120,20 @@ const styles = StyleSheet.create({
container: {
flex: 1,
},
- loader: {
- marginTop: 40,
+ emptyContainer: {
+ paddingVertical: 48,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ emptyTitle: {
+ marginTop: 12,
+ fontSize: 20,
+ fontWeight: '700',
+ },
+ emptySubtitle: {
+ marginTop: 6,
+ fontSize: 14,
+ textAlign: 'center',
+ paddingHorizontal: 40,
},
});
diff --git a/components/Comments/CommentItem.tsx b/components/Comments/CommentItem.tsx
index f7c8b3c2..0738a0c7 100644
--- a/components/Comments/CommentItem.tsx
+++ b/components/Comments/CommentItem.tsx
@@ -59,7 +59,6 @@ const CommentItem = ({
const colorScheme = useColorScheme() ?? 'light';
const textColor = useThemeColor({}, 'text');
const iconColor = useThemeColor({}, 'icon');
- const theme = useTheme();
const borderColor =
colorScheme === 'dark'
? 'rgba(31, 41, 55, 0.5)'
@@ -171,7 +170,7 @@ const CommentItem = ({
]
}
>
-
+
void;
}
-const updateCommentsCache = (
- queryClient: any,
- postId: string,
- activeTab: string,
- updateFn: (pages: CommentResponse[]) => CommentResponse[],
-) => {
- const commentsQueryKey = getVerificationCommentsInfiniteQueryKey({
- path: { verification_id: postId },
- query: { sort_by: activeTab as any },
- });
- queryClient.setQueryData(commentsQueryKey, (old: any) => {
- if (!old) return old;
- return {
- ...old,
- pages: old.pages.map((page: CommentResponse[]) => updateFn(page)),
- };
- });
-};
-
const CommentsList = memo(
({ postId, ListHeaderComponent }: CommentsListProps) => {
const [activeTab, setActiveTab] = useAtom(activeTabAtom);
@@ -87,6 +71,29 @@ const CommentsList = memo(
query: { sort_by: activeTab as any },
});
+ const updateCommentsCache = (
+ postId: string,
+ activeTab: 'recent' | 'top',
+ updateFn: (pages: CommentResponse[]) => CommentResponse[],
+ ) => {
+ const commentsQueryKey = getVerificationCommentsInfiniteQueryKey({
+ path: { verification_id: postId },
+ query: { sort_by: activeTab },
+ });
+ queryClient.setQueryData(
+ commentsQueryKey,
+ (old: InfiniteData) => {
+ if (!old) return old;
+ return {
+ ...old,
+ pages: old.pages.map((page) => ({
+ comments: updateFn(page.comments),
+ })),
+ };
+ },
+ );
+ };
+
const {
data,
isLoading,
@@ -122,7 +129,7 @@ const CommentsList = memo(
);
// Remove the comment optimistically
- updateCommentsCache(queryClient, postId, activeTab, (page) =>
+ updateCommentsCache(postId, activeTab, (page) =>
page.filter((comment) => comment.comment.id !== commentId),
);
@@ -144,7 +151,7 @@ const CommentsList = memo(
if (!user) return;
// Optimistic update
- updateCommentsCache(queryClient, postId, activeTab, (page) =>
+ updateCommentsCache(postId, activeTab, (page) =>
page.map((comment) =>
comment.comment.id === commentId
? {
@@ -214,7 +221,6 @@ const CommentsList = memo(
},
[handleLikeComment, handleDeleteComment, user?.id, postId],
);
-
const allComments = data?.pages.flatMap((page) => page.comments) || [];
if (error) {
return (
diff --git a/components/ContactSyncSheet/AddUserFromOtherApps.tsx b/components/ContactSyncSheet/AddUserFromOtherApps.tsx
index a3e2a19b..8bd968a2 100644
--- a/components/ContactSyncSheet/AddUserFromOtherApps.tsx
+++ b/components/ContactSyncSheet/AddUserFromOtherApps.tsx
@@ -2,7 +2,7 @@
import React from 'react';
import { View, TouchableOpacity, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
-import Share, { ShareSingleOptions, Social } from 'react-native-share';
+import { Share } from 'react-native';
import ContactListHeader from './ContactListHeader';
import { Text } from '@/components/ui/text';
import { Telegram } from '@/lib/icons/Telegram';
@@ -18,29 +18,24 @@ const AddUserFromOtherApps: React.FC = () => {
const shareMessage = `https://${app_name_slug}.ge/links/${user?.username}`;
- const shareToApp = async (app: keyof typeof Share.Social) => {
- const shareOptions: ShareSingleOptions = {
- title: 'Share via',
- message: 'แฌแแแ WAL แแ',
- url: shareMessage,
- social: Share.Social[app] as Social,
- };
-
+ const shareToApp = async (_app?: string) => {
try {
- await Share.shareSingle(shareOptions);
+ await Share.share({
+ title: 'Share via',
+ message: `แฌแแแ WAL แแ ${shareMessage}`,
+ url: shareMessage,
+ });
} catch (error) {
- console.error(`Error sharing to ${app}:`, error);
+ console.error('Error sharing:', error);
}
};
const shareToOthers = async () => {
- const shareOptions = {
- message: 'แแแแ',
- url: `https://${app_name_slug}.ge/links/${user?.username}`,
- };
-
try {
- await Share.open(shareOptions);
+ await Share.share({
+ message: 'แแแแ',
+ url: `https://${app_name_slug}.ge/links/${user?.username}`,
+ });
} catch (error) {
console.error('Error sharing:', error);
}
diff --git a/components/ContactSyncSheet/ContactItem.tsx b/components/ContactSyncSheet/ContactItem.tsx
index cae2ba20..4079f521 100644
--- a/components/ContactSyncSheet/ContactItem.tsx
+++ b/components/ContactSyncSheet/ContactItem.tsx
@@ -36,7 +36,6 @@ const ContactItem: React.FC = ({
}) => {
const auth = useAuth();
const theme = useTheme();
-
const handlePress = async () => {
if (alreadyOnApp) {
onAddPress();
diff --git a/components/ContactSyncSheet/ContactSyncFriendItem.tsx b/components/ContactSyncSheet/ContactSyncFriendItem.tsx
index 62b3ac9e..a0509333 100644
--- a/components/ContactSyncSheet/ContactSyncFriendItem.tsx
+++ b/components/ContactSyncSheet/ContactSyncFriendItem.tsx
@@ -1,4 +1,3 @@
-// @ts-nocheck
import React, { useState } from 'react';
import {
View,
@@ -10,7 +9,6 @@ import {
import { Text } from '@/components/ui/text';
import { User } from '@/lib/api/generated/types.gen';
import { Ionicons } from '@expo/vector-icons';
-import UserAvatarChallange from '../UserAvatarAnimated';
import { MenuView } from '@react-native-menu/menu';
import useBlockUser from '@/hooks/useBlockUser';
import { FontSizes, useTheme } from '@/lib/theme';
@@ -47,7 +45,7 @@ const ContactSyncFriendItem: React.FC = ({
},
{
text: t('common.block'),
- onPress: () => blockUser.mutate(user.id),
+ onPress: () => blockUser.mutate({ path: { target_id: user.id } }),
style: 'destructive',
},
],
@@ -70,20 +68,20 @@ const ContactSyncFriendItem: React.FC = ({
- {user.profile_picture ? (
+ {user.photos?.[0]?.image_url?.[0] ? (
) : (
- {user.username.charAt(0).toUpperCase()}
+ {user.username?.charAt(0).toUpperCase()}
)}
diff --git a/components/ContactSyncSheet/FriendRequestChip.tsx b/components/ContactSyncSheet/FriendRequestChip.tsx
new file mode 100644
index 00000000..e577de63
--- /dev/null
+++ b/components/ContactSyncSheet/FriendRequestChip.tsx
@@ -0,0 +1,182 @@
+import React from 'react';
+import {
+ View,
+ TouchableOpacity,
+ StyleSheet,
+ ActivityIndicator,
+} from 'react-native';
+import { Text } from '@/components/ui/text';
+import { Avatar, AvatarImage } from '@/components/ui/avatar';
+import { Ionicons } from '@expo/vector-icons';
+import { useTheme } from '@/lib/theme';
+import { FriendRequest, User } from '@/lib/api/generated';
+
+interface FriendRequestChipProps {
+ user: User;
+ request: FriendRequest;
+ onAccept: (id: string) => void;
+ onReject: (id: string) => void;
+ isAccepting: boolean;
+ isRejecting: boolean;
+}
+
+const AVATAR_SIZE = 56;
+
+const FriendRequestChip: React.FC = ({
+ user,
+ request,
+ onAccept,
+ onReject,
+ isAccepting,
+ isRejecting,
+}) => {
+ const theme = useTheme();
+ const imageUrl = user.photos?.[0]?.image_url?.[0];
+ const isPendingIncoming =
+ request.status === 'pending' && request.sender_id === user.id;
+
+ if (!isPendingIncoming) {
+ return null;
+ }
+
+ return (
+
+
+
+
+ {imageUrl ? (
+
+ ) : (
+
+ {user.username?.charAt(0)?.toUpperCase() || ''}
+
+ )}
+
+
+
+
+ onAccept(request.id)}
+ disabled={isAccepting || isRejecting}
+ >
+ {isAccepting ? (
+
+ ) : (
+
+ )}
+
+ onReject(request.id)}
+ disabled={isAccepting || isRejecting}
+ >
+ {isRejecting ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {user.username || ''}
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ width: 72,
+ marginRight: 12,
+ alignItems: 'center',
+ },
+ avatar: {
+ borderWidth: 2,
+ padding: 4,
+ },
+ avatarInner: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ width: '100%',
+ height: '100%',
+ },
+ initials: {
+ fontSize: 20,
+ fontWeight: '600',
+ },
+ actionsOverlay: {
+ position: 'absolute',
+ bottom: -2,
+ left: 0,
+ right: 0,
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ paddingHorizontal: 2,
+ },
+ actionBtn: {
+ width: 22,
+ height: 22,
+ borderRadius: 11,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ username: {
+ marginTop: 6,
+ fontSize: 12,
+ },
+});
+
+export default FriendRequestChip;
diff --git a/components/ContactSyncSheet/FriendRequests.tsx b/components/ContactSyncSheet/FriendRequests.tsx
index 0bae5834..c6f0a487 100644
--- a/components/ContactSyncSheet/FriendRequests.tsx
+++ b/components/ContactSyncSheet/FriendRequests.tsx
@@ -1,19 +1,22 @@
import React from 'react';
-import { View, StyleSheet } from 'react-native';
+import { View, StyleSheet, FlatList } from 'react-native';
import ContactListHeader from './ContactListHeader';
import FriendRequestItem from './FriendRequestItem';
import { useFriendRequestActions } from '@/hooks/useFriendRequestActions';
import { useFriendRequests } from '@/hooks/useFriendRequests';
import { useTheme } from '@/lib/theme';
+import FriendRequestChip from './FriendRequestChip';
interface FriendRequestsProps {
hideMyRequests?: boolean;
limit?: number;
+ horizontal?: boolean;
}
const FriendRequests: React.FC = ({
hideMyRequests = false,
limit = 999,
+ horizontal = false,
}) => {
const theme = useTheme();
const { friendRequests } = useFriendRequests();
@@ -45,6 +48,38 @@ const FriendRequests: React.FC = ({
return null;
}
+ if (horizontal) {
+ return (
+
+
+
+
+ request.id}
+ renderItem={({ item: { user, request } }) => (
+
+ )}
+ />
+
+ );
+ }
+
return (
{
const queryClient = useQueryClient();
const setCheckedCount = useSetAtom(checkedCountAtom);
const setDisplayedContacts = useSetAtom(displayedContactsAtom);
const isFocused = useIsFocused();
- const { data, isFetchingNextPage } = useInfiniteQuery({
- ...getFriendsListInfiniteOptions(),
- getNextPageParam: (lastPage, pages) => {
- const nextPage = pages.length + 1;
- return lastPage.length === PAGE_SIZE ? nextPage : undefined;
- },
- initialPageParam: 1,
+ const { data, isFetching } = useQuery({
+ ...getFriendsListOptions(),
refetchInterval: isFocused ? 10000 : false,
- subscribed: isFocused,
+ refetchOnWindowFocus: true,
+ // subscribed: isFocused,
});
const deleteFriendMutation = useMutation({
...removeFriendMutation(),
@@ -70,7 +65,7 @@ const FriendsList: React.FC = () => {
});
};
- const friends = data?.pages.flatMap((page) => page) || [];
+ const friends = data || [];
if (friends.length === 0) {
return null;
@@ -98,7 +93,6 @@ const FriendsList: React.FC = () => {
}
/>
))}
- {isFetchingNextPage && }
);
};
diff --git a/components/ContactSyncSheet/index.tsx b/components/ContactSyncSheet/index.tsx
index a8c002c0..490f9376 100644
--- a/components/ContactSyncSheet/index.tsx
+++ b/components/ContactSyncSheet/index.tsx
@@ -74,7 +74,7 @@ const ContactSyncSheet = ({ bottomSheetRef }: ContactSyncSheetProps) => {
const { onboardingState, markTutorialAsSeen } = useOnboarding();
// Add user search query
- const { data: searchedUsers, isLoading: isSearching } = useQuery({
+ const { data: foundUser, isLoading: isSearching } = useQuery({
...getUserProfileByUsernameOptions({
path: {
username: searchQuery,
@@ -237,9 +237,10 @@ const ContactSyncSheet = ({ bottomSheetRef }: ContactSyncSheetProps) => {
{ backgroundColor: theme.colors.icon },
]}
>
- {
placeholderTextColor={theme.colors.feedItem.secondaryText}
placeholder={t('common.search_by_name_or_number')}
/>
-
+
- {/* {contactsPermissionGranted && (
-
- {t("common.add_friends")}
-
- )} */}
{!searchQuery && }
{!searchQuery && }
{!searchQuery && }
@@ -298,28 +294,28 @@ const ContactSyncSheet = ({ bottomSheetRef }: ContactSyncSheetProps) => {
))}
{/* Display searched users */}
- {searchQuery && searchedUsers && (
+ {searchQuery && foundUser && (
<>
handleAddSearchedUser(searchedUsers.id)}
- friendRequestSent={sentRequests.has(searchedUsers.id)}
+ image={foundUser.photos?.[0]?.image_url?.[0] || ''}
+ onAddPress={() => handleAddSearchedUser(foundUser.id)}
+ friendRequestSent={sentRequests.has(foundUser.id)}
isLoading={isSendingRequest}
/>
>
)}
- {filteredContacts.length === 0 && !searchedUsers && (
+ {filteredContacts.length === 0 && !foundUser && (
<>
void;
+ onDismiss?: () => void;
+ onPress?: () => void; // toast clickable
+}
+
+export const CountryChangeToast: React.FC = ({
+ countryCode,
+ countryName,
+ onAccept,
+ onDismiss,
+ onPress,
+}) => {
+ const { colors } = useTheme();
+
+ return (
+
+
+
+
+ {t('common.country_change')}
+
+
+
+
+ ร
+
+
+ {t('common.accept')}
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ padding: 14,
+ borderRadius: 16,
+ marginHorizontal: 8,
+ marginVertical: 8,
+ borderWidth: 1,
+ borderColor: 'rgba(255,255,255,0.12)',
+ boxShadow: '0px 2px 8px rgba(0,0,0,0.12)',
+ },
+ flag: {
+ width: 28,
+ height: 18,
+ borderRadius: 3,
+ marginRight: 10,
+ },
+ content: {
+ flex: 1,
+ },
+ title: {
+ fontSize: 15,
+ fontWeight: '600',
+ letterSpacing: -0.24,
+ marginBottom: 2,
+ },
+ subtitle: {
+ fontSize: 14,
+ opacity: 0.85,
+ },
+ actions: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ },
+ pillButton: {
+ paddingHorizontal: 10,
+ paddingVertical: 6,
+ borderRadius: 999,
+ borderWidth: 1,
+ borderColor: 'rgba(255,255,255,0.2)',
+ },
+ accept: {
+ backgroundColor: 'rgba(0, 122, 255, 0.15)',
+ },
+ dismiss: {
+ backgroundColor: 'rgba(255, 59, 48, 0.15)',
+ },
+ pillText: {
+ color: '#fff',
+ fontWeight: '600',
+ },
+});
+
+export default CountryChangeToast;
diff --git a/components/CountrySelector/index.tsx b/components/CountrySelector/index.tsx
index dcd794b3..efc531d1 100644
--- a/components/CountrySelector/index.tsx
+++ b/components/CountrySelector/index.tsx
@@ -27,7 +27,6 @@ const CountrySelector: React.FC = ({
const colorScheme = useColorScheme();
const isDark = colorScheme === 'dark';
const sortedCountries = getSortedCountries();
-
const handleCountrySelect = (country: Country) => {
onSelectCountry(country);
onBack();
diff --git a/components/CreatePost/CreatePostHeader.tsx b/components/CreatePost/CreatePostHeader.tsx
index e102b2d9..78318310 100644
--- a/components/CreatePost/CreatePostHeader.tsx
+++ b/components/CreatePost/CreatePostHeader.tsx
@@ -7,7 +7,6 @@ import {
Platform,
} from 'react-native';
import { Link, useRouter } from 'expo-router';
-import { isIOS } from '@/lib/platform';
import { FontSizes, useTheme } from '@/lib/theme';
import { t } from '@/lib/i18n';
@@ -16,6 +15,7 @@ interface CreatePostHeaderProps {
isDisabled: boolean;
isPending: boolean;
isFactCheckEnabled: boolean;
+ isShareIntent: boolean;
}
export default function CreatePostHeader({
@@ -23,7 +23,9 @@ export default function CreatePostHeader({
isDisabled,
isPending,
isFactCheckEnabled,
+ isShareIntent,
}: CreatePostHeaderProps) {
+ const router = useRouter();
const theme = useTheme();
const getButtonColor = () => {
@@ -42,13 +44,20 @@ export default function CreatePostHeader({
return (
-
-
-
- {t('common.cancel')}
-
-
-
+ {
+ if (isShareIntent) {
+ // TODO: Implement share intent back navigation if needed
+ } else {
+ router.back();
+ }
+ }}
+ >
+
+ {t('common.cancel')}
+
+
{
- router.push({
- pathname: `/(tabs)/(home)/[feedId]/create-post`,
- params: {
- feedId,
- disableImagePicker: 'true',
- },
- });
- }}
- >
-
-
- );
-}
-
-const styles = StyleSheet.create({
- container: {
- position: 'absolute',
- bottom: 16,
- right: 20,
- width: 70,
- height: 70,
- justifyContent: 'center',
- alignItems: 'center',
- flexDirection: 'row',
- },
-});
diff --git a/components/CreatePostGlobal/index.tsx b/components/CreatePostGlobal/index.tsx
deleted file mode 100644
index 47c2b280..00000000
--- a/components/CreatePostGlobal/index.tsx
+++ /dev/null
@@ -1,84 +0,0 @@
-import { TouchableOpacity, StyleSheet, Text } from 'react-native';
-import { useLocalSearchParams, useRouter } from 'expo-router';
-import Ionicons from '@expo/vector-icons/Ionicons';
-import { useTheme } from '@/lib/theme';
-import { useShareIntentContext } from 'expo-share-intent';
-import { useEffect, useRef } from 'react';
-import { isIOS } from '@/lib/platform';
-import { t } from '@/lib/i18n';
-
-interface CreatePostProps {
- disabled: boolean;
- feedId: string;
-}
-
-export default function CreatePostGlobal({
- disabled,
- feedId,
-}: CreatePostProps) {
- const router = useRouter();
- const theme = useTheme();
- const params = useLocalSearchParams<{
- sharedContent: string;
- }>();
-
- const isDark =
- theme.colors.background === '#000000' ||
- theme.colors.background === '#121212';
- const buttonBg = isDark ? '#FFFFFF' : '#000000';
- const contentColor = isDark ? '#000000' : '#FFFFFF';
-
- return (
- {
- router.push({
- pathname: `/(tabs)/(fact-check)/[feedId]/create-post`,
- params: {
- feedId,
- disableRoomCreation: 'true',
- sharedContent: params.sharedContent,
- },
- });
- }}
- >
-
-
- {t('common.check_fact')}
-
-
- );
-}
-
-const styles = StyleSheet.create({
- container: {
- paddingHorizontal: 20,
- paddingVertical: 12,
- borderRadius: 30,
- justifyContent: 'center',
- alignItems: 'center',
- flexDirection: 'row',
- shadowColor: '#000',
- shadowOffset: {
- width: 0,
- height: 3,
- },
- shadowOpacity: 0.3,
- shadowRadius: 4.65,
- elevation: 8,
- },
- icon: {
- marginRight: 8,
- },
- buttonText: {
- fontSize: 16,
- fontWeight: '600',
- },
-});
diff --git a/components/CustomTitle/index.tsx b/components/CustomTitle/index.tsx
index 37862ca4..803c0513 100644
--- a/components/CustomTitle/index.tsx
+++ b/components/CustomTitle/index.tsx
@@ -1,5 +1,4 @@
import useFeed from '@/hooks/useFeed';
-import { useGlobalSearchParams } from 'expo-router';
import Animated, {
useAnimatedStyle,
withSpring,
@@ -9,13 +8,12 @@ import { StyleSheet } from 'react-native';
import { FontSizes, useTheme } from '@/lib/theme';
import { useColorScheme } from '@/lib/useColorScheme';
import { Text } from 'react-native';
-import { H1, H2 } from '../ui/typography';
+import { H1 } from '../ui/typography';
+import { useLocalSearchParams } from 'expo-router';
-function TaskTitle() {
- const { feedId } = useGlobalSearchParams<{ feedId: string }>();
+function TaskTitle({ feedId }: { feedId: string }) {
const { task } = useFeed(feedId);
const { isDarkColorScheme } = useColorScheme();
-
const animatedStyle = useAnimatedStyle(() => {
return {
opacity: task ? withSpring(1) : 0,
diff --git a/components/DateOfBirth/index.tsx b/components/DateOfBirth/index.tsx
index e241ed20..ba8bd3ce 100644
--- a/components/DateOfBirth/index.tsx
+++ b/components/DateOfBirth/index.tsx
@@ -4,10 +4,11 @@ import {
TouchableOpacity,
StyleSheet,
useColorScheme,
+ Platform,
} from 'react-native';
import { Controller } from 'react-hook-form';
import { Text } from '@/components/ui/text';
-import DatePicker from 'react-native-date-picker';
+import DateTimePicker from '@react-native-community/datetimepicker';
import { parse, format } from 'date-fns';
import Animated, {
useSharedValue,
@@ -24,6 +25,7 @@ const AnimatedTouchable = Animated.createAnimatedComponent(TouchableOpacity);
export default function DateOfBirth({ control }: { control: any }) {
const [open, setOpen] = useState(false);
+ const [tempDate, setTempDate] = useState(new Date(2000, 1, 1));
const pressed = useSharedValue(0);
const colorScheme = useColorScheme();
const theme = useTheme();
@@ -78,7 +80,10 @@ export default function DateOfBirth({ control }: { control: any }) {
colorScheme === 'dark' ? '#000' : 'rgba(0,0,0,0.2)',
},
]}
- onPress={() => setOpen(true)}
+ onPress={() => {
+ setTempDate(value ? formatDate(value) : new Date(2000, 1, 1));
+ setOpen(true);
+ }}
onPressIn={() => {
pressed.value = withTiming(1, {
duration: 150,
@@ -124,29 +129,83 @@ export default function DateOfBirth({ control }: { control: any }) {
{value ? t('common.change') : t('common.select')}
- {
- setOpen(false);
- onChange(formatDateToString(date));
- }}
- onCancel={() => {
- setOpen(false);
- }}
- />
+ {open &&
+ (Platform.OS === 'android' ? (
+ {
+ if (event.type === 'set' && date) {
+ onChange(formatDateToString(date));
+ setOpen(false);
+ } else {
+ setOpen(false);
+ }
+ }}
+ />
+ ) : (
+
+
+ {t('common.date_of_birth')}
+
+ {
+ if (date) setTempDate(date);
+ }}
+ />
+
+ {
+ setOpen(false);
+ }}
+ style={{ padding: 12 }}
+ >
+
+ {t('common.cancel')}
+
+
+ {
+ onChange(formatDateToString(tempDate));
+ setOpen(false);
+ }}
+ style={{ padding: 12 }}
+ >
+
+ {t('common.confirm')}
+
+
+
+
+ ))}
>
)}
/>
diff --git a/components/DbUserGetter/index.tsx b/components/DbUserGetter/index.tsx
index 205e4e91..4f2de753 100644
--- a/components/DbUserGetter/index.tsx
+++ b/components/DbUserGetter/index.tsx
@@ -3,16 +3,38 @@ import { useAtomValue } from 'jotai';
import { publicKeyState } from '@/lib/state/auth';
import { isWeb } from '@/lib/platform';
import { useNotificationHandler } from './useNotficationHandler';
+import { useEffect, useState } from 'react';
+import { getDeviceId } from '@/lib/device-id';
+
+function DbUserGetter({
+ children,
+ showMessagePreview,
+}: {
+ children: React.ReactNode;
+ showMessagePreview: boolean;
+}) {
+ const [deviceId, setDeviceId] = useState('');
+
+ useEffect(() => {
+ getDeviceId().then(setDeviceId);
+ }, []);
-function DbUserGetter({ children }: { children: React.ReactNode }) {
const publicKey = useAtomValue(publicKeyState);
- useNotificationHandler();
if (isWeb) {
return children;
}
+
+ if (!deviceId) {
+ return null;
+ }
+
return (
-
+
{children}
);
diff --git a/components/DbUserGetter/useNotficationHandler.ts b/components/DbUserGetter/useNotficationHandler.ts
index 971885b5..c5d8afe9 100644
--- a/components/DbUserGetter/useNotficationHandler.ts
+++ b/components/DbUserGetter/useNotficationHandler.ts
@@ -2,7 +2,6 @@ import { useEffect, useRef } from 'react';
import { useRouter } from 'expo-router';
import * as Notifications from 'expo-notifications';
import { useQueryClient } from '@tanstack/react-query';
-import { useSession } from '../AuthLayer';
import { getUserVerificationOptions } from '@/lib/api/generated/@tanstack/react-query.gen';
interface PendingNavigation {
@@ -15,81 +14,7 @@ interface PendingNavigation {
export function useNotificationHandler() {
const router = useRouter();
const queryClient = useQueryClient();
- const { session, user, isLoading, userIsLoading } = useSession();
- const pendingNavigation = useRef(null);
- const hasCheckedInitialNotification = useRef(false);
-
- // Check for initial notification that launched the app
- useEffect(() => {
- const checkInitialNotification = async () => {
- console.log(
- 'Checking for initial notification...',
- hasCheckedInitialNotification.current,
- );
- if (hasCheckedInitialNotification.current) return;
- hasCheckedInitialNotification.current = true;
-
- try {
- const response = await Notifications.getLastNotificationResponseAsync();
- if (response) {
- console.log(
- 'App launched from notification:',
- response.notification.request.content.data,
- );
- const { type, verificationId, roomId, feedId } =
- response.notification.request.content.data;
-
- // Check if app is ready for immediate navigation
- const isAppReady = !isLoading && !userIsLoading && session && user;
- console.log('App ready status:', isAppReady);
- if (isAppReady) {
- handleNotificationNavigation({
- type,
- verificationId,
- roomId,
- feedId,
- });
- } else {
- console.log(
- 'App not ready, storing initial notification for:',
- type,
- );
- pendingNavigation.current = {
- type,
- verificationId,
- roomId,
- feedId,
- };
- }
- }
- } catch (error) {
- console.error('Error checking initial notification:', error);
- }
- };
-
- checkInitialNotification();
- }, [isLoading, userIsLoading, session, user]);
-
- // Handle pending navigation when app is ready
- useEffect(() => {
- // Check if app is fully ready and we have pending navigation
- const isAppReady = !isLoading && !userIsLoading && session && user;
-
- if (isAppReady && pendingNavigation.current) {
- const { type, verificationId, roomId, feedId } =
- pendingNavigation.current;
-
- console.log('Processing pending notification navigation:', type);
-
- // Clear pending navigation first to prevent multiple executions
- pendingNavigation.current = null;
-
- // Small delay to ensure navigation is stable
- setTimeout(() => {
- handleNotificationNavigation({ type, verificationId, roomId, feedId });
- }, 100);
- }
- }, [isLoading, userIsLoading, session, user]);
+ const lastNotificationResponse = Notifications.useLastNotificationResponse();
const handleNotificationNavigation = ({
type,
@@ -106,7 +31,7 @@ export function useNotificationHandler() {
queryClient.invalidateQueries({
queryKey: queryOptions.queryKey,
});
- router.navigate({
+ router.push({
pathname: '/(tabs)/(home)/verification/[verificationId]',
params: {
verificationId,
@@ -115,31 +40,10 @@ export function useNotificationHandler() {
return;
}
- if (
- (type === 'fact_check_completed' || type === 'video_summary_completed') &&
- verificationId
- ) {
- const queryOptions = getUserVerificationOptions({
- query: {
- verification_id: verificationId,
- },
- });
- queryClient.invalidateQueries({
- queryKey: queryOptions.queryKey,
- });
- router.navigate({
- pathname: '/(tabs)/(fact-check)/verification/[verificationId]',
- params: {
- verificationId,
- },
- });
- return;
- }
-
if (type === 'new_message' && roomId) {
- console.log('Navigating to chat room:', roomId);
- router.navigate({
- pathname: '/(tabs)/(home)/chatrooms/[roomId]',
+ console.log('new_message', roomId);
+ router.push({
+ pathname: '/(chat)/[roomId]',
params: {
roomId: roomId,
},
@@ -148,7 +52,7 @@ export function useNotificationHandler() {
}
if (feedId) {
- router.navigate({
+ router.push({
pathname: '/(tabs)/(home)/[feedId]',
params: {
feedId: feedId,
@@ -166,7 +70,7 @@ export function useNotificationHandler() {
queryClient.invalidateQueries({
queryKey: queryOptions.queryKey,
});
- router.navigate({
+ router.push({
pathname: '/status/[verificationId]',
params: {
verificationId,
@@ -176,18 +80,54 @@ export function useNotificationHandler() {
}
if (type === 'friend_request_sent') {
- router.navigate({
- pathname: '/(tabs)/(home)',
+ router.push({
+ pathname: '/(tabs)/(chat-list)',
});
}
};
+ // Handle notification response when app was opened from a notification
+ useEffect(() => {
+ if (
+ lastNotificationResponse &&
+ lastNotificationResponse.actionIdentifier ===
+ Notifications.DEFAULT_ACTION_IDENTIFIER
+ ) {
+ const data = lastNotificationResponse.notification.request.content.data;
+ const type = data?.type as string;
+ const verificationId = data?.verificationId as string | undefined;
+ const roomId = data?.roomId as string | undefined;
+ const feedId = data?.feedId as string | undefined;
+
+ console.log('Last notification response:', {
+ type,
+ verificationId,
+ roomId,
+ feedId,
+ });
+
+ handleNotificationNavigation({
+ type,
+ verificationId,
+ roomId,
+ feedId,
+ });
+
+ // Clear the last notification response after handling it
+ Notifications.clearLastNotificationResponseAsync();
+ }
+ }, [lastNotificationResponse]);
+
+ // Handle notification responses while app is running
useEffect(() => {
- // Set up background notification handler
const backgroundSubscription =
Notifications.addNotificationResponseReceivedListener((response) => {
- const { type, verificationId, roomId, feedId } =
- response.notification.request.content.data;
+ const data = response.notification.request.content.data;
+ const type = data?.type as string;
+ const verificationId = data?.verificationId as string | undefined;
+ const roomId = data?.roomId as string | undefined;
+ const feedId = data?.feedId as string | undefined;
+
console.log('Notification response received:', {
type,
verificationId,
@@ -195,26 +135,17 @@ export function useNotificationHandler() {
feedId,
});
- // Check if app is ready for immediate navigation
- const isAppReady = !isLoading && !userIsLoading && session && user;
-
- if (isAppReady) {
- // App is ready, navigate immediately
- handleNotificationNavigation({
- type,
- verificationId,
- roomId,
- feedId,
- });
- } else {
- // App not ready, store for deferred navigation
- console.log('App not ready, storing pending navigation for:', type);
- pendingNavigation.current = { type, verificationId, roomId, feedId };
- }
+ // App is ready, navigate immediately
+ handleNotificationNavigation({
+ type,
+ verificationId,
+ roomId,
+ feedId,
+ });
});
return () => {
backgroundSubscription.remove();
};
- }, [isLoading, userIsLoading, session, user]);
+ }, []);
}
diff --git a/components/EnableNotifications/index.tsx b/components/EnableNotifications/index.tsx
index 042c3a39..2c6d451e 100644
--- a/components/EnableNotifications/index.tsx
+++ b/components/EnableNotifications/index.tsx
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from 'react';
import { Linking, Platform, View, StyleSheet } from 'react-native';
import Button from '@/components/Button';
import * as Notifications from 'expo-notifications';
+import type { EventSubscription } from 'expo-notifications';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Text } from '../ui/text';
import { isDev } from '@/lib/api/config';
@@ -21,6 +22,8 @@ import {
} from '@/lib/api/generated/@tanstack/react-query.gen';
import { t } from '@/lib/i18n';
import { trackEvent, setUserProperties } from '@/lib/analytics';
+import { useTheme } from '@/lib/theme';
+import { useColorScheme } from '@/lib/useColorScheme';
export const openNotificationSettings = () => {
return Linking.openSettings();
@@ -31,12 +34,13 @@ export default function EnableNotifications({
}: {
hidden?: boolean;
}) {
+ const colorScheme = useColorScheme();
const queryClient = useQueryClient();
const [expoPushToken, setExpoPushToken] = useAtom(expoPushTokenAtom);
const [isSubscribed, setIsSubscribed] = useAtom(isSubscribedAtom);
const [userDismissed, setUserDismissed] = useState(false);
- const notificationListener = useRef();
- const responseListener = useRef();
+ const notificationListener = useRef(null);
+ const responseListener = useRef(null);
const [notification, setNotification] = useState<
Notifications.Notification | undefined
>(undefined);
@@ -147,12 +151,8 @@ export default function EnableNotifications({
});
return () => {
- notificationListener.current &&
- Notifications.removeNotificationSubscription(
- notificationListener.current,
- );
- responseListener.current &&
- Notifications.removeNotificationSubscription(responseListener.current);
+ notificationListener.current && notificationListener.current.remove();
+ responseListener.current && responseListener.current.remove();
};
}, [hidden, router, userDismissed]);
@@ -163,7 +163,7 @@ export default function EnableNotifications({
{isDev && (
diff --git a/components/FeedItem/MediaContent.tsx b/components/FeedItem/MediaContent.tsx
index 0709e5dd..ea4805ce 100644
--- a/components/FeedItem/MediaContent.tsx
+++ b/components/FeedItem/MediaContent.tsx
@@ -19,7 +19,7 @@ import SimplifiedVideoPlayback from '../SimplifiedVideoPlayback';
import { AutoSizedImage } from '../AutoSizedImage';
import ImageGrid from '../ImageGrid';
import * as MediaLibrary from 'expo-media-library';
-import * as FileSystem from 'expo-file-system';
+import * as FileSystem from 'expo-file-system/legacy';
import { measureHandle } from '@/lib/hooks/useHandleRef';
import { MeasuredDimensions, runOnJS, runOnUI } from 'react-native-reanimated';
import { HandleRef } from '@/lib/hooks/useHandleRef';
@@ -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 ? (
-
-
-
-
-
{(badgeInfo || previewData) && (
<>
{
if (Platform.OS === 'android') {
diff --git a/components/FeedItem/NewsSourcesBottomSheet.tsx b/components/FeedItem/NewsSourcesBottomSheet.tsx
index 8f4f1c27..f19213f1 100644
--- a/components/FeedItem/NewsSourcesBottomSheet.tsx
+++ b/components/FeedItem/NewsSourcesBottomSheet.tsx
@@ -22,6 +22,7 @@ import { useTheme } from '@/lib/theme';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { isAndroid } from '@/lib/platform';
import { Portal } from '@/components/primitives/portal';
+import { t } from '@/lib/i18n';
interface NewsSourcesBottomSheetProps {
bottomSheetRef: React.RefObject;
}
@@ -91,7 +92,7 @@ export default function NewsSourcesBottomSheet({
- แฌแงแแ แ
+ {t('common.sources')}
{activeSources?.map((source, index) => (
(0);
const { subscribeToSpace } = useSubscribeToSpace();
const { startStream, isPending: isStartingStream } = useStartStream();
-
const roomPreview = useQuery({
- ...getRoomPreviewDataSpacePreviewLivekitRoomNameGetOptions({
+ ...getRoomPreviewDataOptions({
path: {
livekit_room_name: roomName,
},
@@ -177,14 +175,14 @@ function SpaceView({ roomName, scheduledAt, description }: SpaceViewProps) {
onPress={() => subscribeToSpace(roomName)}
style={[
styles.listenButton,
- (roomPreview.data?.is_subscribed ||
+ ((roomPreview.data?.is_subscribed as boolean) ||
(!exists && space_state === 'ended')) &&
styles.disabledButton,
]}
>
- {roomPreview.data?.is_subscribed
+ {(roomPreview.data?.is_subscribed as boolean)
? `แแแแฌแงแแแ ${formattedScheduledTime}`
: 'แแแแฌแงแแแ'}
diff --git a/components/FeedItem/SpaceView/useStartStream.ts b/components/FeedItem/SpaceView/useStartStream.ts
index 6ab6887c..56a64946 100644
--- a/components/FeedItem/SpaceView/useStartStream.ts
+++ b/components/FeedItem/SpaceView/useStartStream.ts
@@ -1,17 +1,16 @@
-// @ts-nocheck
import { useMutation } from '@tanstack/react-query';
import { useAtom, useSetAtom } from 'jotai';
import { activeLivekitRoomState } from '@/components/SpacesBottomSheet/atom';
-import { startLiveMutation } from '@/lib/api/generated/@tanstack/react-query.gen';
+import { createStreamMutation } from '@/lib/api/generated/@tanstack/react-query.gen';
export function useStartStream() {
const [activeLivekitRoom, setActiveLivekitRoom] = useAtom(
activeLivekitRoomState,
);
const { mutate: startStream, isPending } = useMutation({
- ...startLiveMutation(),
+ ...createStreamMutation(),
onSuccess: (data) => {
- setActiveLivekitRoom(data);
+ setActiveLivekitRoom(data as any);
},
});
@@ -23,8 +22,9 @@ export function useStartStream() {
) {
setActiveLivekitRoom(null);
} else {
+ console.log('startStream', livekitRoomName);
// API expects body with feed_id/text_content; here treat room name as feed_id surrogate
- startStream({ body: { feed_id: livekitRoomName } } as any);
+ startStream({ body: { livekit_room_name: livekitRoomName } });
}
},
isPending,
diff --git a/components/FeedItem/index.tsx b/components/FeedItem/index.tsx
index 79a5c7e7..0e188c7a 100644
--- a/components/FeedItem/index.tsx
+++ b/components/FeedItem/index.tsx
@@ -40,7 +40,8 @@ function arePropsEqual(prevProps: any, nextProps: any) {
prevProps.ai_video_summary_status === nextProps.ai_video_summary_status &&
prevProps.fact_check_status === nextProps.fact_check_status &&
prevProps.fact_check_data === nextProps.fact_check_data &&
- prevProps.thumbnail === nextProps.thumbnail
+ prevProps.thumbnail === nextProps.thumbnail &&
+ prevProps.liveEndedAt === nextProps.liveEndedAt
);
}
@@ -65,6 +66,7 @@ function FeedItem({
imageGalleryWithDims,
thumbnail,
fact_check_data,
+ liveEndedAt,
}: {
name: string;
time: string;
@@ -86,6 +88,7 @@ function FeedItem({
fact_check_data: FeedPost['fact_check_data'];
previewData: FeedPost['preview_data'];
thumbnail: string;
+ liveEndedAt: FeedPost['live_ended_at'];
}) {
const { user } = useAuth();
const router = useRouter();
@@ -94,9 +97,10 @@ function FeedItem({
const { closeLightbox } = useLightboxControls();
// This can be used for real time information as this is actually polling the data from the server
- const { data: verification } = useVerificationById(verificationId, false, {
+ const { data: verification } = useVerificationById(verificationId, !!isLive, {
refetchInterval: 5000,
});
+
const handleProfilePress = () => {
if (user?.id === posterId) {
return;
@@ -137,7 +141,7 @@ function FeedItem({
return (
);
}, [
@@ -240,13 +247,6 @@ function FeedItem({
{name}
- {/* {affiliatedIcon && (
-
- )} */}
-
ยท {formattedTime}
{hasRecording && (
@@ -318,7 +318,7 @@ function FeedItem({
/>
)}
(null);
- const [appLocale] = useAtom(appLocaleAtom);
const { t } = useTranslation();
useEffect(() => {
if (isAndroid) {
- NavigationBar.setPositionAsync('absolute');
- NavigationBar.setBackgroundColorAsync('transparent');
+ NavigationBar.setVisibilityAsync('hidden');
}
+
+ return () => {
+ if (isAndroid) {
+ NavigationBar.setVisibilityAsync('visible');
+ }
+ };
}, []);
return (
diff --git a/components/HorizontalAnonList/index.tsx b/components/HorizontalAnonList/index.tsx
index d76b5621..33eebce0 100644
--- a/components/HorizontalAnonList/index.tsx
+++ b/components/HorizontalAnonList/index.tsx
@@ -75,8 +75,8 @@ const HorizontalAnonList: React.FC<{ feedId: string }> = ({ feedId }) => {
>
{
-
- Choose app language
-
-
-
diff --git a/components/LiveStreamViewer/index.tsx b/components/LiveStreamViewer/index.tsx
index c00329fc..1017122f 100644
--- a/components/LiveStreamViewer/index.tsx
+++ b/components/LiveStreamViewer/index.tsx
@@ -28,20 +28,17 @@ import TopGradient from '../VideoPlayback/TopGradient';
import CloseButton from '../CloseButton';
import { FontSizes } from '@/lib/theme';
import { t } from '@/lib/i18n';
+import { useIsFocused } from '@react-navigation/native';
// Component for constraining video to portrait aspect ratio
function ConstrainedLiveVideo({ children }: { children: React.ReactNode }) {
// Use 9:16 aspect ratio for portrait video (standard mobile video ratio)
- const aspectRatio = 9 / 16;
-
return (
{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/LiveUserCountIndicator/useCountAnonList.ts b/components/LiveUserCountIndicator/useCountAnonList.ts
index 7abe753c..d0b961e9 100644
--- a/components/LiveUserCountIndicator/useCountAnonList.ts
+++ b/components/LiveUserCountIndicator/useCountAnonList.ts
@@ -10,12 +10,12 @@ function useCountAnonList(feedId: string) {
feed_id: feedId,
},
}),
- refetchInterval: isFocused ? 60000 : false,
+ refetchInterval: isFocused ? 100000 : false,
retry: 1,
- refetchOnWindowFocus: false,
refetchOnMount: false,
subscribed: isFocused,
enabled: isFocused,
+ refetchOnWindowFocus: true,
gcTime: 1000 * 60 * 2,
staleTime: 1000 * 30,
refetchOnReconnect: false,
diff --git a/components/LocationFeed/ListEmptyComponent.tsx b/components/LocationFeed/ListEmptyComponent.tsx
index 412deb30..ea1caff1 100644
--- a/components/LocationFeed/ListEmptyComponent.tsx
+++ b/components/LocationFeed/ListEmptyComponent.tsx
@@ -43,28 +43,7 @@ export function ListEmptyComponent({
return null;
}
- const mainView = isUserInSelectedLocation ? (
- {'...'}
- ) : (
-
-
- {t('common.go_to_location_to_post')}
-
- {selectedLocation &&
- selectedLocation.task &&
- selectedLocation.nearest_location && (
-
-
-
- )}
-
- );
+ const mainView = {'๐ฅฒ'};
return !isFetching && {mainView};
}
@@ -91,6 +70,7 @@ const createStyles = (theme: Theme) =>
height: 400,
textAlign: 'center',
marginBottom: theme.spacing.md * 1.5, // 24px
+ marginTop: theme.spacing.md * 1.5, // 24px
},
instructionText: {
color: theme.colors.text,
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..497931ac 100644
--- a/components/LocationFeed/index.tsx
+++ b/components/LocationFeed/index.tsx
@@ -1,5 +1,12 @@
import { View } from 'react-native';
-import { useEffect, useState, useRef, useCallback, Suspense } from 'react';
+import {
+ useEffect,
+ useState,
+ useRef,
+ useCallback,
+ Suspense,
+ RefObject,
+} from 'react';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { useLocationFeedPaginated } from '@/hooks/useLocationFeedPaginated';
import Animated, {
@@ -26,10 +33,10 @@ import { useRouter, usePathname } from 'expo-router';
import { useLightboxControls } from '@/lib/lightbox/lightbox';
import { shouldFocusCommentInputAtom } from '@/atoms/comments';
import useFeeds from '@/hooks/useFeeds';
-import { getUserVerificationOptions } from '@/lib/api/generated/@tanstack/react-query.gen';
import { ThemedText } from '../ThemedText';
import { getCurrentLocale } from '@/lib/i18n';
import { trackEvent } from '@/lib/analytics';
+import { BottomSheetMethods } from '@gorhom/bottom-sheet/lib/typescript/types';
type Location = {
nearest_location: {
@@ -42,26 +49,20 @@ type Location = {
interface LocationFeedProps {
feedId: string;
- content_type: 'last24h' | 'youtube_only' | 'social_media_only';
+ content_type?: 'last24h' | 'youtube_only' | 'social_media_only';
}
export default function LocationFeed({
feedId,
content_type,
}: LocationFeedProps) {
- const {
- isUserInSelectedLocation,
- selectedLocation,
- isGettingLocation,
- isFactCheckFeed,
- isNewsFeed,
- } = useIsUserInSelectedLocation();
+ const { isUserInSelectedLocation, selectedLocation, isGettingLocation } =
+ useIsUserInSelectedLocation();
const queryClient = useQueryClient();
const router = useRouter();
const pathname = usePathname();
const { closeLightbox } = useLightboxControls();
const [_, setShouldFocusInput] = useAtom(shouldFocusCommentInputAtom);
-
const {
items,
fetchNextPage,
@@ -80,7 +81,6 @@ export default function LocationFeed({
const locationUserListSheetRef = useRef(null);
const { headerHeight } = useFeeds();
-
const flashListRef = useRef(null);
const defaultStoryIndex = 0;
@@ -104,7 +104,7 @@ export default function LocationFeed({
const first = viewableItems[0];
if (first?.item?.id) {
trackEvent('view_item', {
- content_type: isNewsFeed ? 'news' : 'post',
+ content_type: 'post',
item_id: String(first.item.id),
feed_id: String(first.item.feed_id || feedId),
});
@@ -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}
/>
);
},
@@ -279,11 +280,7 @@ export default function LocationFeed({
// Track list view refresh as view_item_list
trackEvent('view_item_list', {
item_list_id: String(feedId),
- item_list_name: isNewsFeed
- ? 'news'
- : isFactCheckFeed
- ? 'fact_check'
- : 'location',
+ item_list_name: 'location',
});
}, [refetch, queryClient, feedId]);
@@ -295,17 +292,14 @@ export default function LocationFeed({
headerOffset={headerHeight}
renderItem={renderItem}
ListHeaderComponent={
- isNewsFeed ? (
-
- {new Date().toLocaleDateString(getCurrentLocale(), {
- month: 'long',
- day: 'numeric',
- })}
-
- ) : undefined
+
+ {new Date().toLocaleDateString(getCurrentLocale(), {
+ month: 'long',
+ day: 'numeric',
+ })}
+
}
+ // @ts-ignore
ListEmptyComponent={
- {!isWeb && !isNewsFeed && (
+ {!isWeb && (
)}
- {!isWeb && !isFactCheckFeed && !isNewsFeed && (
-
+ {!isWeb && (
+
+ }
+ />
)}
>
);
diff --git a/components/LocationUserListSheet/index.tsx b/components/LocationUserListSheet/index.tsx
index ca32ce45..e2bb5dc4 100644
--- a/components/LocationUserListSheet/index.tsx
+++ b/components/LocationUserListSheet/index.tsx
@@ -12,22 +12,22 @@ import {
StyleSheet,
useColorScheme,
BackHandler,
+ TouchableOpacity,
} from 'react-native';
import HorizontalAnonList from '../HorizontalAnonList';
import BottomSheet, {
BottomSheetBackdrop,
BottomSheetBackdropProps,
} from '@gorhom/bottom-sheet';
-import { useLocalSearchParams } from 'expo-router';
+import { useLocalSearchParams, useRouter } from 'expo-router';
import { getBottomSheetBackgroundStyle } from '@/lib/styles';
import { useTheme } from '@/lib/theme';
import { useAtom } from 'jotai';
-import {
- locationUserListSheetState,
- locationUserListfeedIdState,
-} from '@/lib/atoms/location';
+import { locationUserListSheetState } from '@/lib/atoms/location';
import { NativeEventSubscription } from 'react-native';
import { t } from '@/lib/i18n';
+import { Ionicons } from '@expo/vector-icons';
+import { Portal } from '@/components/primitives/portal';
interface LocationUserListSheetProps {
bottomSheetRef: RefObject;
@@ -42,11 +42,13 @@ const LocationUserListSheet = ({
const [isBottomSheetOpen, setIsBottomSheetOpen] = useAtom(
locationUserListSheetState,
);
- const [feedId] = useAtom(locationUserListfeedIdState);
+ const { feedId } = useLocalSearchParams<{ feedId: string }>();
const backHandlerSubscriptionRef = useRef(
null,
);
+ const router = useRouter();
+
const handleSheetChange = useCallback(
(index: number) => {
const isBottomSheetVisible = index >= 0;
@@ -107,25 +109,41 @@ const LocationUserListSheet = ({
);
return (
<>
-
-
-
- {t('common.active')}
-
-
- {visible && feedId && }
-
+
+
+
+
+ {t('common.active')}
+
+ {/* {
+ router.navigate({
+ pathname: "/(tabs)/(home)/create-space",
+ params: { feedId },
+ });
+ bottomSheetRef.current?.close();
+ }}
+ style={[styles.roomButton, { position: 'absolute', right: 16 }]}
+ >
+
+
+
+ */}
+
+ {visible && feedId && }
+
+
>
);
};
@@ -137,14 +155,40 @@ const styles = StyleSheet.create({
justifyContent: 'center',
paddingHorizontal: 16,
position: 'relative',
+ paddingVertical: 15,
},
headerText: {
fontSize: 24,
- height: 40,
fontWeight: 'bold',
- marginBottom: 12,
textAlign: 'center',
},
+ roomButton: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: '#ddd',
+ paddingHorizontal: 16,
+ paddingVertical: 10,
+ borderRadius: 16,
+ },
+ roomButtonText: {
+ fontSize: 15,
+ fontWeight: '600',
+ color: '#007AFF',
+ },
+ roomIconContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ },
+ activeIndicator: {
+ width: 6,
+ height: 6,
+ backgroundColor: '#007AFF',
+ borderRadius: 3,
+ position: 'absolute',
+ top: -2,
+ right: -2,
+ zIndex: 1,
+ },
});
export default LocationUserListSheet;
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/MessageToast.tsx b/components/MessageToast.tsx
new file mode 100644
index 00000000..2b8d54f4
--- /dev/null
+++ b/components/MessageToast.tsx
@@ -0,0 +1,111 @@
+import React from 'react';
+import { Image, StyleSheet, Text, View, TouchableOpacity } from 'react-native';
+import { useTheme } from '@/lib/theme';
+import { router } from 'expo-router';
+import { useQueryClient } from '@tanstack/react-query';
+import { getUserChatRoomsOptions } from '@/lib/api/generated/@tanstack/react-query.gen';
+
+interface MessageToastProps {
+ message: string;
+ senderUsername: string;
+ senderProfilePicture: string;
+ senderId: string;
+ roomId: string;
+}
+
+export const MessageToast: React.FC = ({
+ message,
+ senderUsername,
+ senderProfilePicture,
+ senderId,
+ roomId,
+}) => {
+ const queryClient = useQueryClient();
+
+ const { colors } = useTheme();
+ const handlePress = () => {
+ const queryOptions = getUserChatRoomsOptions();
+
+ // Navigate to the chat room
+ router.push({
+ pathname: '/(chat)/[roomId]',
+ params: {
+ roomId: roomId,
+ },
+ });
+ queryClient.invalidateQueries({
+ queryKey: queryOptions.queryKey,
+ });
+ };
+ return (
+
+
+
+
+ {senderUsername}
+
+
+ {message}
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ padding: 16,
+ borderRadius: 16,
+ backgroundColor: 'rgba(255, 255, 255, 0.95)',
+ margin: 8,
+ borderWidth: 1,
+ borderColor: 'rgba(255, 255, 255, 0.2)',
+ boxShadow: '0px 2px 8px rgba(0, 0, 0, 0.12)',
+ },
+ iconContainer: {
+ marginRight: 12,
+ width: 32,
+ height: 32,
+ borderRadius: 16,
+ backgroundColor: 'rgba(0, 122, 255, 0.1)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ profilePicture: {
+ width: 44,
+ height: 44,
+ borderRadius: 22,
+ marginRight: 12,
+ borderWidth: 2,
+ borderColor: 'rgba(0, 122, 255, 0.2)',
+ },
+ content: {
+ flex: 1,
+ },
+ username: {
+ fontSize: 15,
+ fontWeight: '600',
+ marginBottom: 4,
+ letterSpacing: -0.24,
+ },
+ message: {
+ fontSize: 14,
+ fontWeight: '400',
+ lineHeight: 20,
+ opacity: 0.8,
+ letterSpacing: -0.24,
+ },
+});
diff --git a/components/MuteButton/index.tsx b/components/MuteButton/index.tsx
index 93cd056f..27860867 100644
--- a/components/MuteButton/index.tsx
+++ b/components/MuteButton/index.tsx
@@ -1,3 +1,4 @@
+// @ts-nocheck
import { Volume2, VolumeX } from '@/lib/icons';
import { Button } from '@/components/ui/button';
import { ViewStyle } from 'react-native';
diff --git a/components/Photos/index.tsx b/components/Photos/index.tsx
index 529f0c76..e3b103ae 100644
--- a/components/Photos/index.tsx
+++ b/components/Photos/index.tsx
@@ -91,7 +91,7 @@ export default function Photos({ redirectURL }: { redirectURL?: string }) {
if (redirectURL) {
const defaultCategoryId = '66e82cbf6cf36789fa525eaf';
- router.replace({
+ router.navigate({
pathname: redirectURL as any,
params: {
categoryId: defaultCategoryId,
diff --git a/components/PostControls.tsx b/components/PostControls.tsx
index aae3c6fa..fd5aeb30 100644
--- a/components/PostControls.tsx
+++ b/components/PostControls.tsx
@@ -68,24 +68,6 @@ export default function PostControls({
)}
-
- {/* {!disableRoomCreation && (
- {
- router.replace({
- pathname: "/(tabs)/(home)/[feedId]/create-space",
- params: { feedId },
- });
- }}
- style={styles.roomButton}
- >
-
-
-
-
- แแแแฎแแก แจแแฅแแแ
-
- )} */}
>
@@ -157,11 +139,7 @@ const styles = StyleSheet.create({
paddingVertical: 10,
borderRadius: 16,
},
- roomIconContainer: {
- flexDirection: 'row',
- alignItems: 'center',
- marginRight: 8,
- },
+
activeIndicator: {
width: 6,
height: 6,
@@ -172,11 +150,7 @@ const styles = StyleSheet.create({
right: -2,
zIndex: 1,
},
- roomButtonText: {
- fontSize: 15,
- fontWeight: '600',
- color: '#007AFF',
- },
+
charactersLeft: {
fontSize: 14,
},
diff --git a/components/ProfileHeader/AnimatedStatusBadge.tsx b/components/ProfileHeader/AnimatedStatusBadge.tsx
index d73c5681..a58980f0 100644
--- a/components/ProfileHeader/AnimatedStatusBadge.tsx
+++ b/components/ProfileHeader/AnimatedStatusBadge.tsx
@@ -1,3 +1,4 @@
+// @ts-nocheck
import React, { useEffect, useRef } from 'react';
import { StyleSheet } from 'react-native';
import { atom, useAtom } from 'jotai';
@@ -20,8 +21,8 @@ export function AnimatedStatusBadge() {
const { isDarkColorScheme } = useColorScheme();
// MEMORY LEAK FIX: Add refs to track timers and mounting state
- const timerRef = useRef(null);
- const secondTimerRef = useRef(null);
+ const timerRef = useRef(null);
+ const secondTimerRef = useRef(null);
const isMountedRef = useRef(true);
useEffect(() => {
diff --git a/components/ProfileHeader/index.tsx b/components/ProfileHeader/index.tsx
index 75c9750e..23b0f5cc 100644
--- a/components/ProfileHeader/index.tsx
+++ b/components/ProfileHeader/index.tsx
@@ -1,3 +1,4 @@
+// @ts-nocheck
import React, { useEffect, useRef, useState } from 'react';
import {
View,
@@ -11,10 +12,10 @@ import {
} from 'react-native';
import {
Link,
- useLocalSearchParams,
useRouter,
useGlobalSearchParams,
usePathname,
+ useLocalSearchParams,
} from 'expo-router';
import { TabBarIcon } from '../navigation/TabBarIcon';
import { Text } from '../ui/text';
@@ -50,6 +51,7 @@ import { TabBar } from './TabBar';
import { useUserFeedIds } from '@/hooks/useUserFeedIds';
function ProfileHeader({
+ feedId,
customTitle,
customTitleComponent,
isAnimated = true,
@@ -57,6 +59,7 @@ function ProfileHeader({
showSearch = false,
showLocationTabs = false,
showTabs = false,
+ content_type,
}: {
customTitle?: string;
customTitleComponent?: React.ReactNode;
@@ -65,6 +68,8 @@ function ProfileHeader({
showSearch?: boolean;
showLocationTabs?: boolean;
showTabs?: boolean;
+ feedId?: string;
+ content_type?: string;
}) {
const pathname = usePathname();
@@ -72,10 +77,6 @@ function ProfileHeader({
const setHeaderHeight = useSetAtom(HEADER_HEIGHT);
const setHeaderHeightWithTabs = useSetAtom(HEADER_HEIGHT_WITH_TABS);
const { isDarkColorScheme } = useColorScheme();
- const { feedId, content_type } = useGlobalSearchParams<{
- feedId: string;
- content_type: string;
- }>();
const router = useRouter();
const activeTab = content_type;
@@ -88,7 +89,6 @@ function ProfileHeader({
isFetching: isLocationFetching,
errorMsg: locationError,
} = useLocationsInfo(categoryId, showLocationTabs);
- const { goLiveMutation } = useGoLive();
// Search state
const [isSearchActive, setIsSearchActive] = useAtom(isSearchActiveAtom);
@@ -140,13 +140,6 @@ function ProfileHeader({
feedId: tabKey,
},
});
- if (!isWeb && !locationError) {
- goLiveMutation.mutateAsync({
- body: {
- feed_id: tabKey,
- },
- });
- }
return;
}
@@ -163,13 +156,6 @@ function ProfileHeader({
feedId: actualfeedId,
},
});
- if (!isWeb && !locationError) {
- goLiveMutation.mutateAsync({
- body: {
- feed_id: actualfeedId,
- },
- });
- }
}
}
};
@@ -312,23 +298,7 @@ function ProfileHeader({
/>
{/* Only show other buttons when search is not active */}
- {!isSearchActive && !showSearch && (
- <>
- {customButtons}
- {!customButtons && (
-
-
-
-
-
-
-
- )}
- >
- )}
+ {!isSearchActive && !showSearch && <>{customButtons}>}
)}
@@ -343,6 +313,7 @@ function ProfileHeader({
= ({ onRegionChange }) => {
return (
{
const selectedRegion = nativeEvent.event as Region;
if (
@@ -85,34 +85,37 @@ const RegionSelector: React.FC = ({ onRegionChange }) => {
}
}}
shouldOpenOnLongPress={false}
- actions={[
- {
- id: 'georgia',
- title: `๐ฌ๐ช ${getRegionDisplayName('georgia')}`,
- state: preferredRegion === 'georgia' ? 'on' : 'off',
- },
- // {
- // id: "united_states",
- // title: `๐บ๐ธ ${getRegionDisplayName("united_states")}`,
- // state: preferredRegion === "united_states" ? "on" : "off",
- // },
- // {
- // id: "france",
- // title: `๐ซ๐ท ${getRegionDisplayName("france")}`,
- // state: preferredRegion === "france" ? "on" : "off",
- // },
- ]}
+ actions={
+ [
+ // {
+ // id: 'georgia',
+ // title: `๐ฌ๐ช ${getRegionDisplayName('georgia')}`,
+ // state: preferredRegion === 'georgia' ? 'on' : 'off',
+ // },
+ // {
+ // id: "united_states",
+ // title: `๐บ๐ธ ${getRegionDisplayName("united_states")}`,
+ // state: preferredRegion === "united_states" ? "on" : "off",
+ // },
+ // {
+ // id: "france",
+ // title: `๐ซ๐ท ${getRegionDisplayName("france")}`,
+ // state: preferredRegion === "france" ? "on" : "off",
+ // },
+ ]
+ }
>
{
- if (Platform.OS === 'android') {
- menuRef.current?.show();
- }
+ // if (Platform.OS === 'android') {
+ // menuRef.current?.show();
+ // }
}}
+ disabled
>
- {t('settings.preferred_region')}
+ {t('settings.preferred_country')}
updateUser({
@@ -139,6 +139,13 @@ export default function RegisterView() {
username && !/^[a-zA-Z0-9_\.]*$/.test(username),
);
+ const userNameExists =
+ usernameQuery.data &&
+ !usernameQuery.error &&
+ !!usernameQuery.dataUpdatedAt &&
+ !usernameQuery.isFetching &&
+ !!usernameQuery.data?.username;
+
// Check if username is valid and available
const isUsernameValid =
username &&
@@ -146,18 +153,7 @@ export default function RegisterView() {
username.length <= MAX_USERNAME_LENGTH &&
!hasNonLatinChars &&
!errors.username &&
- !usernameQuery.error &&
- !usernameQuery.isFetching &&
- !!usernameQuery.data;
-
- useEffect(() => {
- if (usernameQuery.data?.username === null) {
- errorToast({
- title: t('errors.username_taken'),
- description: t('errors.username_taken'),
- });
- }
- }, [usernameQuery.data]);
+ !userNameExists;
useEffect(() => {
if (username) {
@@ -188,6 +184,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
}
@@ -200,12 +199,8 @@ export default function RegisterView() {
return '#ef4444'; // Red for validation errors
}
- if (usernameQuery.data?.username === null) {
- return '#ef4444'; // Red for unavailable username
- }
-
- if (isUsernameValid && usernameQuery.data?.username !== null) {
- return '#22c55e'; // Green for valid and available
+ if (isUsernameValid && usernameQuery.data?.username) {
+ return '#737373';
}
if (!isUsernameValid) {
return '#ef4444'; // Red for invalid username
@@ -220,31 +215,17 @@ 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)';
};
const insets = useSafeAreaInsets();
+
return (
-
- handleLogout()} variant="back" />
-
แกแแฎแแแ
)}
+ {userNameExists && (
+
+ {t('errors.username_taken')}
+
+ )}
{errors.username && !hasNonLatinChars && (
{errors.username.message}
)}
- {usernameQuery.data?.username === null &&
+ {!usernameQuery.data &&
!hasNonLatinChars &&
!errors.username && (
- {usernameQuery.data.message}
+ {usernameQuery.data?.message}
)}
- = MAX_USERNAME_LENGTH
- ? '#ef4444'
- : '#9ca3af',
- },
- ]}
- >
- {username.length}/{MAX_USERNAME_LENGTH}
-
+
)}
@@ -332,7 +311,7 @@ export default function RegisterView() {
updateUserMutation.isPending ||
!isValid ||
!isUsernameValid ||
- hasNonLatinChars
+ debouncedUsername !== username
}
size="lg"
variant="secondary"
@@ -352,75 +331,16 @@ const styles = StyleSheet.create({
container: {
flex: 1,
},
- waitlistContainer: {
- flex: 1,
- justifyContent: 'center',
- alignItems: 'center',
- padding: 16,
- },
- successText: {
- fontSize: 20,
- color: '#22c55e',
- marginBottom: 8,
- },
- centerText: {
- textAlign: 'center',
- },
- closeButtonContainer: {
- position: 'absolute',
- top: 0,
- left: 0,
- zIndex: 10,
- padding: 8,
- },
formContainer: {
- paddingVertical: 30,
paddingHorizontal: 20,
flex: 1,
marginTop: 40,
},
- inputWrapper: {
- marginBottom: 16,
- },
- inputContainer: {
- backgroundColor: 'rgba(38, 38, 38, 0.8)',
- borderRadius: 10,
- paddingHorizontal: 16,
- paddingTop: 24,
- paddingBottom: 12,
- position: 'relative',
- },
- floatingLabel: {
- position: 'absolute',
- left: 6,
- top: 24,
- fontSize: 16,
- color: '#9ca3af',
- fontWeight: '500',
- },
- usernameInput: {
- color: 'white',
- fontSize: 18,
- backgroundColor: 'transparent',
- height: 30,
- padding: 0,
- fontWeight: '500',
- marginTop: 10,
- },
- inputFooter: {
- flexDirection: 'row',
- justifyContent: 'flex-end',
- marginTop: 2,
- },
inputFeedback: {
flexDirection: 'row',
justifyContent: 'flex-end',
marginTop: 4,
},
- charCounter: {
- fontSize: 14,
- fontWeight: '500',
- },
errorText: {
color: '#ef4444',
fontSize: 14,
@@ -430,22 +350,6 @@ const styles = StyleSheet.create({
sectionTitle: {
marginVertical: 6,
},
- genderContainer: {
- flexDirection: 'column',
- marginVertical: 12,
- },
- genderTitle: {
- marginBottom: 8,
- },
- genderButtonsContainer: {
- flexDirection: 'row',
- },
- genderButton: {
- marginBottom: 12,
- },
- femaleButton: {
- marginLeft: 12,
- },
submitButtonContainer: {
paddingHorizontal: 20,
},
@@ -460,9 +364,7 @@ const styles = StyleSheet.create({
fontWeight: '600',
color: '#000000',
},
- nameInputWrapper: {
- marginBottom: 8,
- },
+ nameInputWrapper: {},
nameInput: {
fontSize: 18,
borderRadius: 12,
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/RetryButton/index.tsx b/components/RetryButton/index.tsx
index b6f987b7..36db379c 100644
--- a/components/RetryButton/index.tsx
+++ b/components/RetryButton/index.tsx
@@ -6,11 +6,20 @@ import { useNavigation, useLocalSearchParams } from 'expo-router';
import { Alert, StyleSheet } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { t } from '@/lib/i18n';
+import { useColorScheme } from '@/lib/useColorScheme';
export default function RetryButton() {
const insets = useSafeAreaInsets();
const navigation = useNavigation();
const { feedId } = useLocalSearchParams<{ feedId: string }>();
+ const { isDarkColorScheme } = useColorScheme();
+ const iconTint = isDarkColorScheme ? '#FFFFFF' : '#000000';
+ const surfaceBg = isDarkColorScheme
+ ? 'rgba(0, 0, 0, 0.5)'
+ : 'rgba(255, 255, 255, 0.85)';
+ const surfaceBorder = isDarkColorScheme
+ ? 'rgba(255,255,255,0.25)'
+ : 'rgba(0,0,0,0.15)';
// Note: Using feedId from params
const handleRetry = async () => {
@@ -42,9 +51,17 @@ export default function RetryButton() {
);
}
diff --git a/components/ScrollableFeedProvider.tsx b/components/ScrollableFeedProvider.tsx
index 33bbaa92..5cb1a99c 100644
--- a/components/ScrollableFeedProvider.tsx
+++ b/components/ScrollableFeedProvider.tsx
@@ -62,21 +62,20 @@ export default function ScrollableFeedProvider({
// };
// onScroll(fakeScrollDownEvent);
// }, 100);
-
// Then simulate scrolling back to top
- setTimeout(() => {
- const fakeScrollUpEvent: NativeScrollEvent = {
- contentOffset: { x: 0, y: 0 },
- contentSize: { width: 0, height: 1000 },
- layoutMeasurement: { width: 0, height: 800 },
- velocity: { x: 0, y: -2 },
- zoomScale: 1,
- contentInset: { top: 0, left: 0, bottom: 0, right: 0 },
- targetContentOffset: { x: 0, y: 0 },
- };
- onScroll(fakeScrollUpEvent);
- snapToClosestState(fakeScrollUpEvent);
- }, 300);
+ // setTimeout(() => {
+ // const fakeScrollUpEvent: NativeScrollEvent = {
+ // contentOffset: { x: 0, y: 0 },
+ // contentSize: { width: 0, height: 1000 },
+ // layoutMeasurement: { width: 0, height: 800 },
+ // velocity: { x: 0, y: -2 },
+ // zoomScale: 1,
+ // contentInset: { top: 0, left: 0, bottom: 0, right: 0 },
+ // targetContentOffset: { x: 0, y: 0 },
+ // };
+ // onScroll(fakeScrollUpEvent);
+ // snapToClosestState(fakeScrollUpEvent);
+ // }, 300);
};
simulateScrollSequence();
diff --git a/components/SentMediaItem/index.tsx b/components/SentMediaItem/index.tsx
index 629a95c1..9a19f3f9 100644
--- a/components/SentMediaItem/index.tsx
+++ b/components/SentMediaItem/index.tsx
@@ -1,10 +1,19 @@
-import React, { useEffect } from 'react';
+import React from 'react';
import MessageItemLayout from '../Chat/message-item-layout';
-import { Text, StyleSheet, View, useColorScheme } from 'react-native';
+import {
+ Text,
+ StyleSheet,
+ View,
+ useColorScheme,
+ Alert,
+ Pressable,
+} from 'react-native';
import { FontSizes } from '@/lib/theme';
import { formatDistanceToNow } from 'date-fns';
import Animated, { FadeIn, SlideInUp } from 'react-native-reanimated';
import { t } from '@/lib/i18n';
+import * as Clipboard from 'expo-clipboard';
+import { useToast } from '../ToastUsage';
interface MessageItemProps {
id: string;
@@ -21,6 +30,7 @@ const AnimatedMessageLayout =
const SentMediaItem: React.FC = React.memo(
({ id, content, isAuthor, createdAt, isLastFromAuthor }) => {
const colorScheme = useColorScheme();
+ const { success } = useToast();
const isDark = colorScheme === 'dark';
const formattedTime = createdAt
? formatDistanceToNow(new Date(createdAt), { addSuffix: false })
@@ -38,6 +48,16 @@ const SentMediaItem: React.FC = React.memo(
.replace('years', t('common.year_short'))
: '';
+ const handleLongPress = React.useCallback(async () => {
+ if (typeof content !== 'string' || !content) return;
+ try {
+ await Clipboard.setStringAsync(content);
+ success({ title: t('common.copied_to_clipboard') });
+ } catch (error) {
+ // noop
+ }
+ }, [content]);
+
return (
= React.memo(
isAuthor ? FadeIn.duration(150) : FadeIn.duration(200).delay(50)
}
>
-
+
= React.memo(
{isAuthor && isLastFromAuthor && createdAt && (
{formattedTime}
)}
-
+
);
},
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/SimpleGoBackHeader/index.tsx b/components/SimpleGoBackHeader/index.tsx
index 7b60f761..3d2fb0ab 100644
--- a/components/SimpleGoBackHeader/index.tsx
+++ b/components/SimpleGoBackHeader/index.tsx
@@ -19,48 +19,62 @@ import { FACT_CHECK_FEED_ID, NEWS_FEED_ID } from '@/lib/constants';
interface SimpleGoBackHeaderProps {
title?: string;
rightSection?: React.ReactNode;
- verificationId?: string;
timestamp?: string;
middleSection?: React.ReactNode;
- // If true, the back button will just go back because user is already auth and is in the global screen and not sharable status screen.
- justInstantGoBack?: boolean;
+ hideBackButton?: boolean;
+ logoutOnClick?: boolean;
+ withInsets?: boolean;
}
const SimpleGoBackHeader = ({
title,
rightSection,
- verificationId,
timestamp,
middleSection,
- justInstantGoBack,
+ hideBackButton,
+ logoutOnClick,
+ withInsets,
}: SimpleGoBackHeaderProps) => {
- const { user } = useAuth();
+ const { user, logout } = useAuth();
const router = useRouter();
const theme = useTheme();
-
+ const insets = useSafeAreaInsets();
const Header = () =>
!isWeb && (
- {
- if (justInstantGoBack) {
- router.back();
- return;
- }
- if (user) {
- router.replace(`/(tabs)/(news)/${NEWS_FEED_ID}`);
- } else {
- router.navigate('/(auth)/sign-in');
- }
- }}
- style={{ color: theme.colors.text }}
- />
+ {!hideBackButton ? (
+ {
+ if (logoutOnClick) {
+ logout();
+ router.replace('/(auth)/sign-in');
+ return;
+ }
+ if (router.canGoBack()) {
+ router.back();
+ return;
+ } else {
+ if (user) {
+ router.replace(`/(tabs)/(home)`);
+ } else {
+ router.navigate('/(auth)/sign-in');
+ }
+ }
+ }}
+ style={{ color: theme.colors.text }}
+ />
+ ) : (
+
+ )}
{middleSection ||
((title || timestamp) && (
- {sourcesLength} แฌแงแแ แ
+ {sourcesLength} {t('common.sources')}
@@ -135,14 +137,12 @@ function SimpleGoBackHeaderPost({
]);
if (!verificationId) {
- return >} />;
+ return >} />;
}
return (
<>
-
+
+ }
+ />
>
);
}
diff --git a/components/SpacesBottomSheet/SpacesBottomControls.tsx b/components/SpacesBottomSheet/SpacesBottomControls.tsx
index 8fe14e2a..1d8e3581 100644
--- a/components/SpacesBottomSheet/SpacesBottomControls.tsx
+++ b/components/SpacesBottomSheet/SpacesBottomControls.tsx
@@ -11,7 +11,12 @@ import { BottomSheetTextInput, BottomSheetView } from '@gorhom/bottom-sheet';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useHaptics } from '@/lib/haptics';
import { t } from '@/lib/i18n';
+import { useMutation } from '@tanstack/react-query';
+import { invokeAgentMutation } from '@/lib/api/generated/@tanstack/react-query.gen';
function SpacesBottomControls({ isHost }: { isHost: boolean }) {
+ const { mutate: invokeAgent } = useMutation({
+ ...invokeAgentMutation(),
+ });
const connectionState = useConnectionState();
const livekitRoom = useAtomValue(activeLivekitRoomState);
const { localParticipant } = useLocalParticipant();
@@ -43,18 +48,10 @@ function SpacesBottomControls({ isHost }: { isHost: boolean }) {
const haptic = useHaptics();
const canSpeak =
isHost || (localMetadata?.invited_to_stage && localMetadata?.hand_raised);
-
if (connectionState !== 'connected') return null;
return (
-
+
{showSearch && livekitRoom?.is_host && (
)}
+ {livekitRoom?.is_host && (
+ {
+ haptic('Light');
+ invokeAgent({
+ query: {
+ room_name: livekitRoom?.livekit_room_name || '',
+ },
+ });
+ }}
+ style={styles.searchButton}
+ >
+
+
+ )}
{livekitRoom?.is_host && (
{
diff --git a/components/SpacesBottomSheet/SpacesSheetHeader.tsx b/components/SpacesBottomSheet/SpacesSheetHeader.tsx
index 0bf94ebc..a04634a9 100644
--- a/components/SpacesBottomSheet/SpacesSheetHeader.tsx
+++ b/components/SpacesBottomSheet/SpacesSheetHeader.tsx
@@ -23,7 +23,7 @@ const SpacesSheetHeader: React.FC = ({
const roomPreview = useQuery({
...getRoomPreviewDataOptions({
path: {
- room_name: livekitRoom?.livekit_room_name,
+ livekit_room_name: livekitRoom?.livekit_room_name,
},
}),
enabled: !!livekitRoom?.livekit_room_name,
diff --git a/components/SpacesBottomSheet/Viewers.tsx b/components/SpacesBottomSheet/Viewers.tsx
index a923ffef..2ef4ce68 100644
--- a/components/SpacesBottomSheet/Viewers.tsx
+++ b/components/SpacesBottomSheet/Viewers.tsx
@@ -1,4 +1,4 @@
-// @ts-nocheck
+//@ts-nocheck
import {
useConnectionState,
useIOSAudioManagement,
@@ -6,6 +6,7 @@ import {
useParticipantInfo,
useParticipants,
useRoomContext,
+ useTrackTranscription,
} from '@livekit/react-native';
import {
View,
@@ -31,6 +32,7 @@ import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { useHaptics } from '@/lib/haptics';
import { FontSizes } from '@/lib/theme';
import { t } from '@/lib/i18n';
+import { RemoteParticipant } from 'livekit-client';
export default function PresenceDialog({
isHost = false,
@@ -41,7 +43,6 @@ export default function PresenceDialog({
const { localParticipant } = useLocalParticipant();
const participants = useParticipants();
const participantSearch = useAtomValue(participantSearchState);
-
const room = useRoomContext();
useIOSAudioManagement(room);
@@ -140,7 +141,7 @@ function ParticipantListItem({
isSpeaker,
isHost,
}: {
- participant: any;
+ participant: RemoteParticipant;
metadata: {
avatar_image: string;
username: string;
@@ -152,7 +153,9 @@ function ParticipantListItem({
isSpeaker: boolean;
isHost: boolean;
}) {
- const imageUrl = metadata?.avatar_image;
+ const imageUrl =
+ metadata?.avatar_image ||
+ 'https://media.springernature.com/full/springer-static/image/art%3A10.1038%2Fnature.2013.14108/MediaObjects/41586_2013_Article_BFnature201314108_Figb_HTML.jpg';
const { removeFromStage } = useRemoveFromStage();
const { inviteToStage } = useInviteToStage();
const liveKitRoom = useAtomValue(activeLivekitRoomState);
@@ -216,9 +219,7 @@ function ParticipantListItem({
source={{ uri: convertToCDNUrl(imageUrl) }}
/>
) : (
-
- {metadata?.username?.[0] || 'N/A'}
-
+ {'N/A'}
)}
@@ -230,7 +231,7 @@ function ParticipantListItem({
)}
- {metadata?.username}
+ {metadata?.username || 'Robert'}
{isSpeaking ? (
diff --git a/components/SpacesBottomSheet/WaveAudio.tsx b/components/SpacesBottomSheet/WaveAudio.tsx
index 8ff686cd..6e8e9bda 100644
--- a/components/SpacesBottomSheet/WaveAudio.tsx
+++ b/components/SpacesBottomSheet/WaveAudio.tsx
@@ -7,14 +7,12 @@ import Animated, {
withTiming,
withDelay,
useSharedValue,
- withSpring,
} from 'react-native-reanimated';
const WaveAudio = () => {
const bars = Array(3)
.fill(0)
.map(() => useSharedValue(1));
-
React.useEffect(() => {
bars.forEach((bar, index) => {
bar.value = withDelay(
@@ -38,11 +36,7 @@ const WaveAudio = () => {
}));
return (
-
+
);
})}
@@ -55,13 +49,14 @@ const styles = StyleSheet.create({
justifyContent: 'space-between',
alignItems: 'center',
width: 24,
- height: 10,
+ height: 14,
marginTop: 7,
},
bar: {
width: 6,
- height: '100%',
- borderRadius: 5,
+ height: 14,
+ borderRadius: 2,
+ backgroundColor: '#007AFF',
},
});
diff --git a/components/SpacesBottomSheet/index.tsx b/components/SpacesBottomSheet/index.tsx
index 1dfb5e6d..43097bf5 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,8 +10,8 @@ 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';
+import { Alert } from 'react-native';
interface SpacesBottomSheetProps {
isVisible?: boolean;
onClose?: () => void;
@@ -29,7 +27,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) => (
@@ -44,23 +42,26 @@ const SpacesBottomSheet = React.forwardRef<
);
const sheetBackgroundStyle = getBottomSheetBackgroundStyle();
-
// renders
const renderFooter = useCallback(
- (props: any) => (
-
- {activeLivekitRoom && (
-
- )}
-
- ),
+ (props: any) => {
+ const insets = useSafeAreaInsets();
+ return (
+
+ {activeLivekitRoom && (
+
+ )}
+
+ );
+ },
[activeLivekitRoom],
);
if (!activeLivekitRoom) {
return null;
}
-
return (
<>
{!isWeb && (
@@ -68,6 +69,7 @@ const SpacesBottomSheet = React.forwardRef<
serverUrl={'wss://ment-6gg5tj49.livekit.cloud'}
token={activeLivekitRoom.livekit_token}
onError={(error: Error) => {
+ Alert.alert('Error', error.message);
// toast(error.message);
}}
connect={true}
@@ -94,7 +96,7 @@ const SpacesBottomSheet = React.forwardRef<
android_keyboardInputMode={'adjustResize'}
topInset={insets.top}
snapPoints={snapPoints}
- enableDynamicSizing={false}
+ enableDynamicSizing={true}
enablePanDownToClose={true}
backdropComponent={renderBackdrop}
backgroundStyle={sheetBackgroundStyle}
diff --git a/components/SpacesBottomSheet/useInviteToStage.ts b/components/SpacesBottomSheet/useInviteToStage.ts
index 04c8b7ad..eae2fbef 100644
--- a/components/SpacesBottomSheet/useInviteToStage.ts
+++ b/components/SpacesBottomSheet/useInviteToStage.ts
@@ -11,7 +11,6 @@ export function useInviteToStage() {
inviteToStage: (vars: {
livekit_room_name: string;
participant_identity: string;
- user_id: string;
- }) => (inviteToStage as any)({ body: vars }),
+ }) => inviteToStage({ body: vars }),
};
}
diff --git a/components/SpacesBottomSheet/useRaiseHand.ts b/components/SpacesBottomSheet/useRaiseHand.ts
index d0fbbaaf..505b68f3 100644
--- a/components/SpacesBottomSheet/useRaiseHand.ts
+++ b/components/SpacesBottomSheet/useRaiseHand.ts
@@ -19,7 +19,7 @@ export default function useRaiseHand() {
}
}, [localMetadata?.hand_raised]);
- const timerRef = useRef();
+ const timerRef = useRef | undefined>(undefined);
useEffect(() => {
return () => {
@@ -41,10 +41,10 @@ export default function useRaiseHand() {
setIsHandRaised,
isPending,
raiseHand: (roomName: string) =>
- (raiseHand as any)({
+ raiseHand({
body: {
livekit_room_name: roomName,
- user_id: localParticipant.identity,
+ participant_identity: localParticipant.identity,
},
}),
isHandRaised,
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/SubmitButton/index.tsx b/components/SubmitButton/index.tsx
index ea33eb4d..1b8361b4 100644
--- a/components/SubmitButton/index.tsx
+++ b/components/SubmitButton/index.tsx
@@ -7,7 +7,7 @@ import { useLocalSearchParams } from 'expo-router';
import Button from '@/components/Button';
import { compressVideo } from '@/lib/media/video/compress';
import { compressIfNeeded } from '@/lib/media/manip';
-import { useTheme } from '@/lib/theme';
+import { useColorScheme } from '@/lib/useColorScheme';
export default function SubmitButton({
mediaBlob,
@@ -25,12 +25,18 @@ export default function SubmitButton({
const { feedId } = useLocalSearchParams<{
feedId: string;
}>();
- const theme = useTheme();
+ const { isDarkColorScheme } = useColorScheme();
+ const iconTint = isDarkColorScheme ? '#FFFFFF' : '#000000';
+ const surfaceBg = isDarkColorScheme
+ ? 'rgba(0, 0, 0, 0.5)'
+ : 'rgba(255, 255, 255, 0.85)';
+ const surfaceBorder = isDarkColorScheme
+ ? 'rgba(255,255,255,0.25)'
+ : 'rgba(0,0,0,0.15)';
const [isProcessing, setIsProcessing] = React.useState(false);
const { uploadBlob } = useUploadVideo({
feedId: feedId as string,
isPhoto,
- isLocationUpload: true,
});
const handleSubmit = async () => {
setIsProcessing(true);
@@ -88,14 +94,21 @@ export default function SubmitButton({
return (
);
}
diff --git a/components/TakeVideo.tsx b/components/TakeVideo.tsx
index f3db85d4..9b7d1e20 100644
--- a/components/TakeVideo.tsx
+++ b/components/TakeVideo.tsx
@@ -3,41 +3,26 @@ 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(
- `lastRecordedVideoPath_${feedId}`,
- );
- if (cachedVideoPath) {
- router.navigate({
- pathname: `/(camera)/mediapage`,
- params: {
- feedId: feedId as string,
- path: cachedVideoPath,
- type: 'video',
- },
- });
- } else {
- router.navigate({
- pathname: `/(camera)/record`,
- params: {
- feedId: feedId as string,
- },
- });
- }
+ router.navigate({
+ pathname: `/record`,
+ params: {
+ feedId: feedId as string,
+ },
+ });
} catch (e) {
console.error('Error accessing camera or cached video:', e);
}
diff --git a/components/TaskBottomOverlay/index.tsx b/components/TaskBottomOverlay/index.tsx
deleted file mode 100644
index b7223f04..00000000
--- a/components/TaskBottomOverlay/index.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import React from 'react';
-import { LinearGradient } from 'expo-linear-gradient';
-import { SAFE_AREA_PADDING } from '../CameraPage/Constants';
-import { useSafeAreaInsets } from 'react-native-safe-area-context';
-
-export default function TaskBottomOverlay({
- children,
-}: {
- children: React.ReactNode;
-}) {
- const insets = useSafeAreaInsets();
-
- return (
-
- {children}
-
- );
-}
diff --git a/components/Toast.tsx b/components/Toast.tsx
index 2c7f114f..995a4f47 100644
--- a/components/Toast.tsx
+++ b/components/Toast.tsx
@@ -35,21 +35,6 @@ interface ToastProps {
onHeightChange?: (id: string, height: number) => void;
}
-const getBackgroundColor = (type: ToastVariant) => {
- switch (type) {
- case 'success':
- return 'rgba(16, 185, 129, 0.95)';
- case 'error':
- return 'rgba(239, 68, 68, 0.95)';
- case 'warning':
- return 'rgba(245, 158, 11, 0.95)';
- case 'info':
- return 'rgba(59, 130, 246, 0.95)';
- default:
- return 'rgba(38, 38, 38, 0.95)';
- }
-};
-
const getIconForType = (type: ToastVariant) => {
switch (type) {
case 'success':
@@ -59,15 +44,15 @@ const getIconForType = (type: ToastVariant) => {
case 'warning':
return 'โ ';
case 'info':
- return 'โน';
+ return '';
+ case 'message':
+ return '';
default:
return '';
}
};
export const Toast: React.FC = ({ toast, index }) => {
- const prevContentRef = useRef(null);
- const prevTypeRef = useRef(null);
const prevIndexRef = useRef(-1);
const { dismiss } = useToast();
@@ -77,8 +62,6 @@ export const Toast: React.FC = ({ toast, index }) => {
);
const scale = useSharedValue(0.9);
const rotateZ = useSharedValue(0);
- const height = useSharedValue(0);
- const viewRef = useRef(null);
const getStackOffset = () => {
const baseOffset = 4;
@@ -157,8 +140,8 @@ export const Toast: React.FC = ({ toast, index }) => {
stiffness: 140,
mass: 0.8,
velocity: 0,
- restDisplacementThreshold: 0.001,
- restSpeedThreshold: 0.001,
+ overshootClamping: false,
+ energyThreshold: 0.001,
});
scale.value = withSpring(getStackScale(), {
@@ -241,7 +224,6 @@ export const Toast: React.FC = ({ toast, index }) => {
}, 250);
};
- const backgroundColor = getBackgroundColor(toast.options.type);
const icon = getIconForType(toast.options.type);
return (
@@ -270,7 +252,6 @@ export const Toast: React.FC = ({ toast, index }) => {
},
shadowOpacity: 0.3,
shadowRadius: 4,
- elevation: 5,
},
]}
onPress={handlePress}
@@ -280,8 +261,10 @@ export const Toast: React.FC = ({ toast, index }) => {
{typeof toast.content === 'string' ? (
{toast.content}
- ) : (
+ ) : React.isValidElement(toast.content) ? (
toast.content
+ ) : (
+ {String(toast.content)}
)}
{toast.options.action && (
@@ -315,7 +298,6 @@ const styles = StyleSheet.create({
},
shadowOpacity: 0.25,
shadowRadius: 12,
- elevation: 12,
},
toast: {
flexDirection: 'row',
diff --git a/components/ToastUsage.tsx b/components/ToastUsage.tsx
index 8eca0b90..f172db5b 100644
--- a/components/ToastUsage.tsx
+++ b/components/ToastUsage.tsx
@@ -1,5 +1,10 @@
import { ToastProvider, useToast } from '@/lib/context/ToastContext';
-import type { ToastOptions, ToastProps } from '@/lib/types/Toast.types';
+import type {
+ ToastOptions,
+ ToastProps,
+ MessageToastOptions,
+ UploadingToastOptions,
+} from '@/lib/types/Toast.types';
import * as React from 'react';
import { ToastViewport } from './ToastViewport';
type ToastRef = {
@@ -11,6 +16,8 @@ type ToastRef = {
) => void;
dismiss?: (id: string) => void;
dismissAll?: () => void;
+ message?: (options: MessageToastOptions) => string;
+ uploading?: (options: UploadingToastOptions) => string;
};
const toastRef: ToastRef = {};
@@ -22,6 +29,8 @@ const ToastController: React.FC = () => {
toastRef.update = toast.update;
toastRef.dismiss = toast.dismiss;
toastRef.dismissAll = toast.dismissAll;
+ toastRef.message = toast.message;
+ toastRef.uploading = toast.uploading;
return null;
};
@@ -79,6 +88,24 @@ export const Toast = {
}
return toastRef.dismissAll();
},
+ message: (options: MessageToastOptions): string => {
+ if (!toastRef.message) {
+ console.warn(
+ 'Toast provider not initialized. Make sure you have wrapped your app with ToastProviderWithViewport.',
+ );
+ return '';
+ }
+ return toastRef.message(options);
+ },
+ uploading: (options: UploadingToastOptions): string => {
+ if (!toastRef.uploading) {
+ console.warn(
+ 'Toast provider not initialized. Make sure you have wrapped your app with ToastProviderWithViewport.',
+ );
+ return '';
+ }
+ return toastRef.uploading(options);
+ },
};
export { ToastProvider, useToast } from '@/lib/context/ToastContext';
@@ -86,4 +113,5 @@ export type {
ToastOptions,
ToastPosition,
ToastType,
+ MessageToastOptions,
} from '@/lib/types/Toast.types';
diff --git a/components/ToastViewport.tsx b/components/ToastViewport.tsx
index 2724a784..bf159565 100644
--- a/components/ToastViewport.tsx
+++ b/components/ToastViewport.tsx
@@ -3,9 +3,12 @@ import React from 'react';
import { StyleSheet, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Toast } from './Toast';
+import { usePathname, useSegments } from 'expo-router';
export const ToastViewport: React.FC = () => {
const { toasts } = useToast();
+ const segments = useSegments() as string[];
+ const hideToasts = segments.includes('(chat-list)');
const insets = useSafeAreaInsets();
const topToasts = toasts.filter((toast) => toast.options.position === 'top');
@@ -25,10 +28,12 @@ export const ToastViewport: React.FC = () => {
},
]}
>
- {topToasts.map((toast, arrayIndex) => {
- const displayIndex = topToasts.length - 1 - arrayIndex;
- return ;
- })}
+ {topToasts
+ .filter((toast) => !hideToasts)
+ .map((toast, arrayIndex) => {
+ const displayIndex = topToasts.length - 1 - arrayIndex;
+ return ;
+ })}
void;
+ previewUri?: string;
+}
+
+export const UploadingToast: React.FC = ({
+ label = 'Uploadingโฆ',
+ progress,
+ mediaKind,
+ cancellable = true,
+ onCancel,
+ previewUri,
+}) => {
+ const colorScheme = useColorScheme();
+ const { colors } = useTheme();
+ const clampedProgress = useMemo(() => {
+ return Math.max(0, Math.min(1, progress));
+ }, [progress]);
+
+ const progressValue = useSharedValue(0);
+
+ useEffect(() => {
+ progressValue.value = withTiming(clampedProgress, {
+ duration: 220,
+ easing: Easing.out(Easing.cubic),
+ });
+ }, [clampedProgress, progressValue]);
+
+ const fillStyle = useAnimatedStyle(() => ({
+ width: `${progressValue.value * 100}%`,
+ }));
+
+ const Icon = mediaKind === 'photo' ? ImageIcon : Film;
+
+ // Apple-like gray color for progress bar
+ const progressColor = colorScheme.isDarkColorScheme ? '#8E8E93' : '#8E8E93';
+
+ return (
+
+
+
+ {typeof previewUri === 'string' && previewUri.length > 0 ? (
+
+ {mediaKind === 'photo' ? (
+
+ ) : (
+
+ )}
+
+ ) : (
+
+
+
+ )}
+ {label}
+
+
+ {/* */}
+
+ {Math.round(clampedProgress * 100)}%
+
+ {cancellable ? (
+
+
+
+ ) : null}
+
+
+
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ padding: 14,
+ borderRadius: 16,
+ borderWidth: 1,
+ // iOS-like subtle border; color provided from theme via style
+ boxShadow: '0px 6px 16px rgba(0,0,0,0.15)',
+ },
+ headerRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ marginBottom: 10,
+ },
+ leftRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ },
+ rightRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 10 as unknown as number,
+ },
+ iconWrap: {
+ width: 28,
+ height: 28,
+ borderRadius: 8,
+ borderWidth: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginRight: 10,
+ },
+ previewWrap: {
+ width: 28,
+ height: 28,
+ borderRadius: 6,
+ overflow: 'hidden',
+ borderWidth: 1,
+ marginRight: 10,
+ backgroundColor: 'rgba(0,0,0,0.06)',
+ },
+ previewMedia: {
+ width: '100%',
+ height: '100%',
+ },
+ title: {
+ fontSize: 16,
+ fontWeight: '600',
+ letterSpacing: -0.24,
+ },
+ percent: {
+ marginLeft: 8,
+ fontSize: 14,
+ fontWeight: '500',
+ opacity: 0.8,
+ },
+ cancelButton: {
+ marginLeft: 6,
+ width: 28,
+ height: 28,
+ borderRadius: 14,
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderWidth: 1,
+ },
+ progressTrack: {
+ height: 8,
+ borderRadius: 6,
+ overflow: 'hidden',
+ },
+ progressFill: {
+ height: '100%',
+ borderRadius: 6,
+ },
+});
+
+export default UploadingToast;
diff --git a/components/UserAvatar/index.tsx b/components/UserAvatar/index.tsx
index 3c130c95..0ec96375 100644
--- a/components/UserAvatar/index.tsx
+++ b/components/UserAvatar/index.tsx
@@ -44,7 +44,7 @@ const UserAvatarLayout = ({
const styles = StyleSheet.create({
avatar: {
- padding: 4,
+ padding: 3,
borderWidth: 2,
alignItems: 'center',
justifyContent: 'center',
diff --git a/components/UserAvatarAnimated/index.tsx b/components/UserAvatarAnimated/index.tsx
deleted file mode 100644
index 7693d394..00000000
--- a/components/UserAvatarAnimated/index.tsx
+++ /dev/null
@@ -1,178 +0,0 @@
-import React, { useEffect, useState } from 'react';
-import { View } from 'react-native';
-import { Text } from '@/components/ui/text';
-import { Avatar, AvatarImage } from '@/components/ui/avatar';
-import cn from 'clsx';
-import { User } from '@/lib/api/generated';
-import { Skeleton } from '../ui/skeleton';
-import { Ionicons } from '@expo/vector-icons';
-import * as Progress from 'react-native-progress';
-import Animated, {
- useSharedValue,
- useAnimatedStyle,
- withTiming,
- Easing,
-} from 'react-native-reanimated';
-import UserAvatarLayout, { AvatarWidth } from '../UserAvatar';
-import { useTheme } from '@/lib/theme';
-
-export default function UserAvatarAnimated({
- size = 'md',
- user,
- isLoading,
- isSuccess,
- color = 'green',
- showName = false,
-}: {
- user: User;
- size?: 'sm' | 'md' | 'lg';
- isLoading?: boolean;
- isSuccess?: boolean;
- color?: 'green' | 'pink' | 'blue' | 'gray';
- showName?: boolean;
-}) {
- const theme = useTheme();
- const imageUrl =
- user.photos && user.photos.length > 0
- ? user.photos[0].image_url[0]
- : undefined;
-
- const [progress, setProgress] = useState(0);
- const progressOpacity = useSharedValue(1);
- const checkmarkOpacity = useSharedValue(0);
- const imageOpacity = useSharedValue(1);
-
- useEffect(() => {
- if (!isSuccess) {
- setProgress(0);
- progressOpacity.value = withTiming(0, { duration: 300 });
- checkmarkOpacity.value = withTiming(0, { duration: 300 });
- imageOpacity.value = withTiming(1, { duration: 300 });
- }
- }, [isSuccess]);
-
- const progressAnimatedStyle = useAnimatedStyle(() => {
- return {
- opacity: progressOpacity.value,
- };
- });
-
- const checkmarkAnimatedStyle = useAnimatedStyle(() => {
- return {
- opacity: checkmarkOpacity.value,
- };
- });
-
- const imageAnimatedStyle = useAnimatedStyle(() => {
- return {
- opacity: imageOpacity.value,
- };
- });
-
- useEffect(() => {
- let interval: NodeJS.Timeout;
- if (isLoading) {
- progressOpacity.value = withTiming(1, { duration: 300 });
- checkmarkOpacity.value = withTiming(0, { duration: 300 });
- imageOpacity.value = withTiming(1, { duration: 300 });
- interval = setInterval(() => {
- setProgress((prevProgress) => {
- if (prevProgress >= 1) {
- clearInterval(interval);
- return 1;
- }
- return prevProgress + 0.33;
- });
- }, 50);
- } else if (isSuccess) {
- progressOpacity.value = withTiming(0, { duration: 300 });
- checkmarkOpacity.value = withTiming(1, {
- duration: 300,
- easing: Easing.inOut(Easing.ease),
- });
- imageOpacity.value = withTiming(0.5, { duration: 300 });
- } else {
- setProgress(0);
- progressOpacity.value = withTiming(0, { duration: 300 });
- checkmarkOpacity.value = withTiming(0, { duration: 300 });
- imageOpacity.value = withTiming(1, { duration: 300 });
- }
-
- return () => {
- if (interval) clearInterval(interval);
- };
- }, [isLoading, isSuccess]);
-
- return (
-
-
-
- {imageUrl ? (
-
- ) : (
-
- {user?.username || 'N/A'}
-
- )}
-
-
-
-
-
-
-
-
- {showName && (
-
-
- {user.username}
-
-
- )}
-
- );
-}
-
-export const UserAvatarAnimatedSkeleton = ({
- size = 'md',
-}: {
- size: 'sm' | 'md' | 'lg';
-}) => {
- return (
-
-
-
- );
-};
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/UserLogin/index.tsx b/components/UserLogin/index.tsx
index 82479ab0..091ffae7 100644
--- a/components/UserLogin/index.tsx
+++ b/components/UserLogin/index.tsx
@@ -105,7 +105,7 @@ const UserLogin = forwardRef(function UserLogin(_, ref) {
backgroundComponent={CustomBottomSheetBackground}
handleIndicatorStyle={{ backgroundColor: isDark ? 'white' : 'black' }}
>
-
+ } />
);
});
diff --git a/components/VerificationView/CommentsView.tsx b/components/VerificationView/CommentsView.tsx
index feea6d21..9ae5cd6a 100644
--- a/components/VerificationView/CommentsView.tsx
+++ b/components/VerificationView/CommentsView.tsx
@@ -5,6 +5,7 @@ import React, {
memo,
useState,
useEffect,
+ RefObject,
} from 'react';
import {
View,
@@ -18,7 +19,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';
@@ -43,6 +44,10 @@ import FactCheckBottomSheet from '../FactCheckBottomSheet';
import { useUniqueSources } from '@/utils/sourceUtils';
import useFeeds from '@/hooks/useFeeds';
import { t } from '@/lib/i18n';
+import {
+ BottomSheetMethods,
+ BottomSheetModalMethods,
+} from '@gorhom/bottom-sheet/lib/typescript/types';
// Tab types for news content
type NewsTab = 'neutral' | 'opposition' | 'government';
@@ -236,7 +241,7 @@ const PostContent = memo(
}: {
verification: LocationFeedPost;
verificationId: string;
- user: any;
+ user?: User;
}) => {
const theme = useTheme();
@@ -302,7 +307,7 @@ const PostContent = memo(
@@ -310,7 +315,7 @@ const PostContent = memo(
@@ -345,7 +350,7 @@ const PostContent = memo(
@@ -402,16 +407,15 @@ const PostContent = memo(
)
)}
{hasPreview && !imageUrl && !realTimeImageUrl && (
@@ -486,7 +491,7 @@ const CommentsView = ({
verification: initialVerification,
verificationId,
}: {
- verification: LocationFeedPost;
+ verification: FeedPost;
verificationId: string;
}) => {
const { user } = useAuth();
@@ -507,8 +512,16 @@ const CommentsView = ({
const keyboardVerticalOffset = useKeyboardVerticalOffset();
return (
-
-
+
+ }
+ />
+
+ }
+ />
-
-
-
+ {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 (
,
React.ElementRef
>(isTextChildren(children) ? <>> : children, {
- ...mergeProps(pressableSlotProps, children.props),
+ ...mergeProps(pressableSlotProps, children.props as AnyProps),
ref: forwardedRef
? composeRefs(forwardedRef, (children as any).ref)
: (children as any).ref,
@@ -51,7 +51,7 @@ const View = React.forwardRef, RNViewProps>(
React.ComponentPropsWithoutRef,
React.ElementRef
>(isTextChildren(children) ? <>> : children, {
- ...mergeProps(viewSlotProps, children.props),
+ ...mergeProps(viewSlotProps, children.props as AnyProps),
ref: forwardedRef
? composeRefs(forwardedRef, (children as any).ref)
: (children as any).ref,
@@ -74,7 +74,7 @@ const Text = React.forwardRef, RNTextProps>(
React.ComponentPropsWithoutRef,
React.ElementRef
>(isTextChildren(children) ? <>> : children, {
- ...mergeProps(textSlotProps, children.props),
+ ...mergeProps(textSlotProps, children.props as AnyProps),
ref: forwardedRef
? composeRefs(forwardedRef, (children as any).ref)
: (children as any).ref,
@@ -103,7 +103,7 @@ const Image = React.forwardRef<
React.ComponentPropsWithoutRef,
React.ElementRef
>(isTextChildren(children) ? <>> : children, {
- ...mergeProps(imageSlotProps, children.props),
+ ...mergeProps(imageSlotProps, children.props as AnyProps),
ref: forwardedRef
? composeRefs(forwardedRef, (children as any).ref)
: (children as any).ref,
diff --git a/components/ui/FactCheckBox.tsx b/components/ui/FactCheckBox.tsx
index 4f64d1dd..b1fc0684 100644
--- a/components/ui/FactCheckBox.tsx
+++ b/components/ui/FactCheckBox.tsx
@@ -118,7 +118,7 @@ const FactCheckSourcesDisplay = ({
]}
>
- {references.length} {t('common.based_on_sources')}
+ {references.length} {t('common.sources')}
diff --git a/components/ui/Tab.tsx b/components/ui/Tab.tsx
index 9184f4db..e53d5fed 100644
--- a/components/ui/Tab.tsx
+++ b/components/ui/Tab.tsx
@@ -1,3 +1,4 @@
+// @ts-nocheck
import React from 'react';
import { Pressable, PressableProps } from 'react-native';
import { cn } from '@/lib/utils';
diff --git a/components/ui/UsernameProgressBar.tsx b/components/ui/UsernameProgressBar.tsx
new file mode 100644
index 00000000..cb2ca2e1
--- /dev/null
+++ b/components/ui/UsernameProgressBar.tsx
@@ -0,0 +1,144 @@
+import React, { useEffect, useMemo } from 'react';
+import { View, StyleSheet, useColorScheme } from 'react-native';
+import Animated, {
+ Easing,
+ useAnimatedStyle,
+ useSharedValue,
+ withTiming,
+} from 'react-native-reanimated';
+import { Text } from '@/components/ui/text';
+
+interface UsernameProgressBarProps {
+ current: number;
+ max: number;
+ width?: number | string;
+ height?: number;
+}
+
+export default function UsernameProgressBar({
+ current,
+ max,
+ width = 120,
+ height = 2,
+}: UsernameProgressBarProps) {
+ const colorScheme = useColorScheme();
+ const isDark = colorScheme === 'dark';
+
+ const clampedProgress = useMemo(() => {
+ 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/components/ui/switch.tsx b/components/ui/switch.tsx
index 844c0075..e9063c97 100644
--- a/components/ui/switch.tsx
+++ b/components/ui/switch.tsx
@@ -1,3 +1,4 @@
+// @ts-nocheck
import * as SwitchPrimitives from '@rn-primitives/switch';
import * as React from 'react';
import { Platform } from 'react-native';
diff --git a/components/ui/typography.tsx b/components/ui/typography.tsx
index 413245fd..341fcc60 100644
--- a/components/ui/typography.tsx
+++ b/components/ui/typography.tsx
@@ -1,3 +1,4 @@
+// @ts-nocheck
import * as Slot from '~/components/primitives/slot';
import { SlottableTextProps, TextRef } from '~/components/primitives/types';
import * as React from 'react';
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/hooks/useAuth.ts b/hooks/useAuth.ts
index 895751da..b73a25b7 100644
--- a/hooks/useAuth.ts
+++ b/hooks/useAuth.ts
@@ -11,21 +11,9 @@ function useAuth() {
setAuthUser(null);
await logout();
- queryClient.resetQueries({
- queryKey: ['user-matches'],
- });
- queryClient.invalidateQueries({
- queryKey: ['user-matches'],
- });
queryClient.clear();
},
reset: () => {
- queryClient.resetQueries({
- queryKey: ['user-matches'],
- });
- queryClient.invalidateQueries({
- queryKey: ['user-matches'],
- });
queryClient.clear();
},
setAuthUser,
diff --git a/hooks/useCheckLocation.ts b/hooks/useCheckLocation.ts
deleted file mode 100644
index a83fd527..00000000
--- a/hooks/useCheckLocation.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-// @ts-nocheck
-import { useState, useEffect } from 'react';
-import { useQuery } from '@tanstack/react-query';
-import { CheckLocationResponse } from '@/lib/api/generated';
-import { checkLocationOptions } from '@/lib/api/generated/@tanstack/react-query.gen';
-
-function useCheckLocation(feedId: string, latitude: number, longitude: number) {
- const { data, isFetching, isSuccess, isError } =
- useQuery({
- ...checkLocationOptions({
- query: {
- feed_id: feedId,
- latitude: latitude,
- longitude: longitude,
- },
- }),
- enabled: !!feedId,
- retry: 2,
- refetchOnMount: true,
- retryDelay: 1000,
- refetchOnWindowFocus: false,
- });
-
- const [isInLocation, setIsInLocation] = useState(false);
- const [nearestLocation, setNearestLocation] = useState<{
- name: string;
- address: string;
- coordinates: number[];
- } | null>(null);
-
- useEffect(() => {
- if (isSuccess && data) {
- setIsInLocation(data[0]);
- if (data[1]) {
- setNearestLocation({
- name: data[1].name,
- address: data[1].address,
- coordinates: data[1].location,
- });
- }
- }
- }, [isSuccess, data]);
-
- return {
- isInLocation,
- nearestLocation,
- isFetching,
- isError,
- isSuccess,
- };
-}
-
-export default useCheckLocation;
diff --git a/hooks/useCreateSpace.ts b/hooks/useCreateSpace.ts
index 1c397810..e86145d9 100644
--- a/hooks/useCreateSpace.ts
+++ b/hooks/useCreateSpace.ts
@@ -5,14 +5,13 @@ import { useToast } from '@/components/ToastUsage';
import { t } from '@/lib/i18n';
export const useCreateSpace = () => {
- const queryClient = useQueryClient();
const router = useRouter();
const { success, error: errorToast } = useToast();
return useMutation({
...createSpaceMutation(),
onSuccess: (data) => {
- queryClient.invalidateQueries({ queryKey: ['spaces'] });
+ // queryClient.invalidateQueries({ queryKey: ['spaces'] });
// Different success message based on whether it's scheduled or not
const successMessage = data.scheduled_at
diff --git a/hooks/useDefaultCountry.ts b/hooks/useDefaultCountry.ts
index 9ee853a4..681e760e 100644
--- a/hooks/useDefaultCountry.ts
+++ b/hooks/useDefaultCountry.ts
@@ -28,11 +28,15 @@ export const useDefaultCountry = () => {
};
return {
country,
+ newsFeedId: data?.news_feed_id,
+ factCheckFeedId: data?.fact_check_feed_id,
isLoading,
error,
countryCode: data,
setCountry: (countryCode: string) => {
- queryClient.setQueryData(getCountryQueryKey(), countryCode);
+ queryClient.setQueryData(getCountryQueryKey(), {
+ country_code: countryCode,
+ });
},
};
};
diff --git a/hooks/useFeed.ts b/hooks/useFeed.ts
index ceb72764..e2cd3f5c 100644
--- a/hooks/useFeed.ts
+++ b/hooks/useFeed.ts
@@ -14,7 +14,6 @@ export default function useFeed(feedId: string) {
}),
enabled: !!feedId,
refetchOnReconnect: false,
- refetchOnWindowFocus: false,
refetchIntervalInBackground: false,
});
diff --git a/hooks/useFeeds.ts b/hooks/useFeeds.ts
index 55a2be9a..a40dc561 100644
--- a/hooks/useFeeds.ts
+++ b/hooks/useFeeds.ts
@@ -1,32 +1,28 @@
import { HEADER_HEIGHT, HEADER_HEIGHT_WITH_TABS } from '@/lib/constants';
-import { useLocalSearchParams } from 'expo-router';
+import {
+ useGlobalSearchParams,
+ useLocalSearchParams,
+ useSegments,
+} from 'expo-router';
import { useAtomValue } from 'jotai';
import { useUserFeedIds } from './useUserFeedIds';
export default function useFeeds() {
+ // @ts-ignore
+ // @ts-ignore
+ // This component should be used carefully as useGlobalSearchParams causes rerender everywhere when params change.
const { feedId } = useLocalSearchParams<{ feedId: string }>();
const hhtabs = useAtomValue(HEADER_HEIGHT_WITH_TABS);
const hh = useAtomValue(HEADER_HEIGHT);
-
+ const hasFeedId = feedId !== undefined && feedId !== null && feedId !== '';
// Use user's preferred feed IDs instead of hardcoded constants
- const { factCheckFeedId, newsFeedId } = useUserFeedIds();
-
- const isFactCheckFeed = factCheckFeedId === feedId;
- const isNewsFeed = newsFeedId === feedId;
- const finalHeight = isFactCheckFeed
- ? hhtabs
- : isNewsFeed
- ? hh
- : !isNewsFeed && !isFactCheckFeed && !feedId
- ? hh
- : hhtabs;
+ const { categoryId } = useUserFeedIds();
+ // having feedID means it's part from (home)
+ const finalHeight = hasFeedId ? hhtabs : hh;
return {
// Add little padding
headerHeight: finalHeight + 10,
- factCheckFeedId,
- newsFeedId,
- isFactCheckFeed,
- isNewsFeed,
+ categoryId,
};
}
diff --git a/hooks/useFriendRequestActions.ts b/hooks/useFriendRequestActions.ts
index b3e3419b..0e7818b4 100644
--- a/hooks/useFriendRequestActions.ts
+++ b/hooks/useFriendRequestActions.ts
@@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
acceptFriendRequestMutation,
getFriendRequestsQueryKey,
+ getFriendsListQueryKey,
rejectFriendRequestMutation,
} from '@/lib/api/generated/@tanstack/react-query.gen';
@@ -26,6 +27,7 @@ export const useFriendRequestActions = () => {
queryClient.invalidateQueries({ queryKey: getFriendRequestsQueryKey() });
},
onSettled: () => {
+ queryClient.invalidateQueries({ queryKey: getFriendsListQueryKey() });
queryClient.invalidateQueries({ queryKey: getFriendRequestsQueryKey() });
},
});
diff --git a/hooks/useFriendRequests.ts b/hooks/useFriendRequests.ts
index 94af790a..48ef32ee 100644
--- a/hooks/useFriendRequests.ts
+++ b/hooks/useFriendRequests.ts
@@ -15,12 +15,12 @@ export function useFriendRequests() {
subscribed: isFocused,
enabled: isFocused,
refetchInterval: isFocused ? 30000 : false,
- refetchOnWindowFocus: false,
refetchOnMount: false,
gcTime: 1000 * 60 * 2,
staleTime: 1000 * 30,
refetchOnReconnect: false,
refetchIntervalInBackground: false,
+ refetchOnWindowFocus: true,
retry: 1,
retryDelay: 1000,
});
diff --git a/hooks/useIsUserInSelectedLocation.ts b/hooks/useIsUserInSelectedLocation.ts
index f86b67f9..4be2ff4c 100644
--- a/hooks/useIsUserInSelectedLocation.ts
+++ b/hooks/useIsUserInSelectedLocation.ts
@@ -1,4 +1,4 @@
-import { useLocalSearchParams } from 'expo-router';
+import { useLocalSearchParams, usePathname } from 'expo-router';
import useLocationsInfo from './useLocationsInfo';
import useFeeds from './useFeeds';
import { useUserFeedIds } from './useUserFeedIds';
@@ -6,20 +6,16 @@ import { useUserFeedIds } from './useUserFeedIds';
export default function useIsUserInSelectedLocation() {
const params = useLocalSearchParams<{ feedId: string }>();
const feedId = params.feedId;
- const { factCheckFeedId, newsFeedId } = useFeeds();
const { categoryId } = useUserFeedIds();
const { data: data, isFetching, isRefetching } = useLocationsInfo(categoryId);
return {
- isUserInSelectedLocation:
- data?.feeds_at_location.some((feed: any) => feed.id === feedId) ||
- factCheckFeedId === feedId ||
- newsFeedId === feedId,
+ isUserInSelectedLocation: data?.feeds_at_location.some(
+ (feed: any) => feed.id === feedId,
+ ),
selectedLocation: data?.nearest_feeds.find(
(item) => item.feed.id === feedId,
),
isGettingLocation: isFetching || isRefetching,
- isFactCheckFeed: factCheckFeedId === feedId,
- isNewsFeed: newsFeedId === feedId,
};
}
diff --git a/hooks/useLiveUser.ts b/hooks/useLiveUser.ts
index 794fbee8..dda9b5af 100644
--- a/hooks/useLiveUser.ts
+++ b/hooks/useLiveUser.ts
@@ -3,16 +3,18 @@ import { useMutation } from '@tanstack/react-query';
import { useRouter } from 'expo-router';
import ProtocolService from '@/lib/services/ProtocolService';
import { createChatRoom } from '@/lib/api/generated';
+import { useToast } from '@/components/ToastUsage';
+import { t } from '@/lib/i18n';
function useLiveUser() {
const router = useRouter();
-
+ const { error: errorToast } = useToast();
const joinChat = useMutation({
mutationFn: async ({ targetUserId }: { targetUserId: string }) => {
// Send keys to server along with room creation
// This actuallys gets the key and doesn't generate it
- const { identityKeyPair, registrationId } =
+ const { identityKeyPair } =
await ProtocolService.generateIdentityKeyPair();
const response = await createChatRoom({
@@ -33,10 +35,17 @@ function useLiveUser() {
return response;
},
+ onError: (error) => {
+ console.log(error);
+ errorToast({
+ title: t('errors.failed_to_join_chat'),
+ description: t('errors.failed_to_join_chat'),
+ });
+ },
onSuccess: (data, variables) => {
if (data.data.chat_room_id) {
router.navigate({
- pathname: '/(tabs)/(home)/chatrooms/[roomId]',
+ pathname: '/(chat)/[roomId]',
params: {
roomId: data.data.chat_room_id,
},
@@ -68,7 +77,7 @@ function useLiveUser() {
onSuccess: (data, variables) => {
if (data.data.chat_room_id) {
router.navigate({
- pathname: '/chatrooms/[roomId]',
+ pathname: '/(chat)/[roomId]',
params: {
roomId: data.data.chat_room_id,
},
diff --git a/hooks/useLocation.ts b/hooks/useLocation.ts
index 6b10badd..aa7063ac 100644
--- a/hooks/useLocation.ts
+++ b/hooks/useLocation.ts
@@ -3,15 +3,16 @@ import * as Location from 'expo-location';
import { isWeb } from '@/lib/platform';
import { t } from '@/lib/i18n';
import { useToast } from '@/lib/context/ToastContext';
+import { useSegments } from 'expo-router';
export default function useLocation() {
const [location, setLocation] = useState(
null,
);
+ const segments = useSegments();
const [errorMsg, setErrorMsg] = useState(null);
const [isGettingLocation, setIsGettingLocation] = useState(true);
const { error: errorToast } = useToast();
-
// Use the location permission hook instead of directly requesting permissions
const [permissionResponse, requestPermission] =
Location.useForegroundPermissions();
@@ -20,6 +21,10 @@ export default function useLocation() {
let locationSubscription: Location.LocationSubscription | null = null;
(async () => {
+ // @ts-ignore
+ if (!segments.includes('(home)') && !segments.includes('record')) {
+ return;
+ }
try {
// Check if we already have permissions, if not request them
if (!permissionResponse || !permissionResponse.granted) {
@@ -86,7 +91,7 @@ export default function useLocation() {
}
}
};
- }, [permissionResponse, requestPermission]);
+ }, [permissionResponse, requestPermission, segments]);
useEffect(() => {
if (errorMsg) {
diff --git a/hooks/useLocationFeedPaginated.ts b/hooks/useLocationFeedPaginated.ts
index 198f13fa..76997a05 100644
--- a/hooks/useLocationFeedPaginated.ts
+++ b/hooks/useLocationFeedPaginated.ts
@@ -1,3 +1,4 @@
+// @ts-nocheck
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
import { isWeb } from '@/lib/platform';
import { useIsFocused } from '@react-navigation/native';
@@ -33,7 +34,7 @@ export function useLocationFeedPaginated({
// Local debounced search state
const [debouncedLocalSearch, setDebouncedLocalSearch] = useState('');
- const debounceTimerRef = useRef(null);
+ const debounceTimerRef = useRef(undefined);
// Debounce the external search term
useEffect(() => {
@@ -64,7 +65,16 @@ export function useLocationFeedPaginated({
// Use the external search term if provided, otherwise use global search
const finalSearchTerm =
externalSearchTerm !== undefined ? debouncedLocalSearch : globalSearchTerm;
-
+ const options = getLocationFeedPaginatedInfiniteOptions({
+ query: {
+ page_size: pageSize,
+ search_term: finalSearchTerm,
+ content_type_filter: content_type,
+ },
+ path: {
+ feed_id: feedId,
+ },
+ });
const {
data,
fetchNextPage,
@@ -78,16 +88,7 @@ export function useLocationFeedPaginated({
hasPreviousPage,
isPending,
} = useInfiniteQuery({
- ...getLocationFeedPaginatedInfiniteOptions({
- query: {
- page_size: pageSize,
- search_term: finalSearchTerm,
- content_type_filter: content_type,
- },
- path: {
- feed_id: feedId,
- },
- }),
+ ...options,
queryFn: async ({ pageParam, queryKey, signal }) => {
const { data } = await getLocationFeedPaginated({
...queryKey,
@@ -129,14 +130,10 @@ export function useLocationFeedPaginated({
retry: 2,
refetchOnMount: false,
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/useLocationSession.ts b/hooks/useLocationSession.ts
index 850dfe7a..4d6f56d8 100644
--- a/hooks/useLocationSession.ts
+++ b/hooks/useLocationSession.ts
@@ -3,6 +3,5 @@ import LocationContext from '@/hooks/context';
export default function useLocationSession() {
const { location, errorMsg, isGettingLocation } = useContext(LocationContext);
-
return { location, errorMsg, isGettingLocation };
}
diff --git a/hooks/useLocationsInfo.ts b/hooks/useLocationsInfo.ts
index e7aa2092..ad8dc633 100644
--- a/hooks/useLocationsInfo.ts
+++ b/hooks/useLocationsInfo.ts
@@ -7,7 +7,6 @@ export default function useLocationsInfo(
categoryId: string,
enabled: boolean = true,
) {
- const isFocused = useIsFocused();
const { location, errorMsg, isGettingLocation } = useLocationSession();
const {
@@ -28,14 +27,18 @@ export default function useLocationsInfo(
}),
enabled: !!categoryId && enabled && (!!location || !!errorMsg),
placeholderData: keepPreviousData,
- subscribed: isFocused,
+ // subscribed: isFocused,
+ staleTime: 1000 * 60 * 5,
});
-
return {
data: locations || {
nearest_feeds: [],
feeds_at_location: [],
},
+ defaultFeedId:
+ locations?.feeds_at_location?.[0]?.id ||
+ locations?.nearest_feeds?.[0]?.feed?.id ||
+ '',
location,
errorMsg,
isFetching: locationsIsFetching || isGettingLocation,
diff --git a/hooks/useMessageRoom.ts b/hooks/useMessageRoom.ts
index b91dee4f..675545f2 100644
--- a/hooks/useMessageRoom.ts
+++ b/hooks/useMessageRoom.ts
@@ -1,8 +1,5 @@
-import { getMessageChatRoom } from '@/lib/api/generated';
import { getMessageChatRoomOptions } from '@/lib/api/generated/@tanstack/react-query.gen';
import { useQuery } from '@tanstack/react-query';
-import { AxiosError } from 'axios';
-import { useEffect } from 'react';
export default function useMessageRoom(
roomId: string,
@@ -19,18 +16,9 @@ export default function useMessageRoom(
room_id: roomId,
},
}),
- retry: false,
- enabled,
- staleTime: 1000 * 60, // Consider data fresh for 1 minute
- gcTime: 1000 * 60 * 10, // Keep data in cache for 10 minutes
- refetchIntervalInBackground: false,
refetchOnWindowFocus: true,
+ enabled: enabled && !!roomId,
+ retry: true,
});
-
- useEffect(() => {
- if (error instanceof AxiosError) {
- // Error handling can be implemented here
- }
- }, [error, room]);
return { room, isFetching: isFetching && !isRefetching, error };
}
diff --git a/hooks/useMessageSpamPrevention.ts b/hooks/useMessageSpamPrevention.ts
new file mode 100644
index 00000000..4c37c418
--- /dev/null
+++ b/hooks/useMessageSpamPrevention.ts
@@ -0,0 +1,94 @@
+import { useRef, useCallback } from 'react';
+
+interface SpamPreventionConfig {
+ timeoutMs: number; // Time window to check for spam
+ maxMessages: number; // Max messages allowed in the time window
+}
+
+const DEFAULT_CONFIG: SpamPreventionConfig = {
+ timeoutMs: 3000, // 3 seconds
+ maxMessages: 5, // 5 messages max in 3 seconds
+};
+
+interface MessageTimestamp {
+ senderId: string;
+ timestamp: number;
+}
+
+export const useMessageSpamPrevention = (
+ config: Partial = {},
+) => {
+ const finalConfig = { ...DEFAULT_CONFIG, ...config };
+ const messageHistoryRef = useRef