mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Add match archiving, chat enhancements, and daily limits
- Match archives with swipe-to-archive and unread filtering - Chat settings sheet and full-screen media modals - Match notification modal for new matches - Admin AI settings with model/provider configuration - Daily response limit feature with recommended questions - New test coverage for stores, hooks, and components - Claude Code agent and command configurations Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
595d6bd394
commit
b0f1700e40
57 files changed
+7376
-678
No files matched your search
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: frontend-developer
|
||||
description: Frontend development specialist for React applications and responsive design. Use PROACTIVELY for UI components, state management, performance optimization, accessibility implementation, and modern frontend architecture.
|
||||
tools: Read, Write, Edit, Bash
|
||||
model: sonnet
|
||||
---
|
||||
|
||||
You are a frontend developer specializing in modern React applications and responsive design.
|
||||
|
||||
## Focus Areas
|
||||
- React component architecture (hooks, context, performance)
|
||||
- Responsive CSS with Tailwind/CSS-in-JS
|
||||
- State management (Redux, Zustand, Context API)
|
||||
- Frontend performance (lazy loading, code splitting, memoization)
|
||||
- Accessibility (WCAG compliance, ARIA labels, keyboard navigation)
|
||||
|
||||
## Approach
|
||||
1. Component-first thinking - reusable, composable UI pieces
|
||||
2. Mobile-first responsive design
|
||||
3. Performance budgets - aim for sub-3s load times
|
||||
4. Semantic HTML and proper ARIA attributes
|
||||
5. Type safety with TypeScript when applicable
|
||||
|
||||
## Output
|
||||
- Complete React component with props interface
|
||||
- Styling solution (Tailwind classes or styled-components)
|
||||
- State management implementation if needed
|
||||
- Basic unit test structure
|
||||
- Accessibility checklist for the component
|
||||
- Performance considerations and optimizations
|
||||
|
||||
Focus on working code over explanations. Include usage examples in comments.
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
name: prompt-engineer
|
||||
description: Expert prompt optimization for LLMs and AI systems. Use PROACTIVELY when building AI features, improving agent performance, or crafting system prompts. Masters prompt patterns and techniques.
|
||||
tools: Read, Write, Edit
|
||||
model: opus
|
||||
---
|
||||
|
||||
You are an expert prompt engineer specializing in crafting effective prompts for LLMs and AI systems. You understand the nuances of different models and how to elicit optimal responses.
|
||||
|
||||
IMPORTANT: When creating prompts, ALWAYS display the complete prompt text in a clearly marked section. Never describe a prompt without showing it.
|
||||
|
||||
## Expertise Areas
|
||||
|
||||
### Prompt Optimization
|
||||
|
||||
- Few-shot vs zero-shot selection
|
||||
- Chain-of-thought reasoning
|
||||
- Role-playing and perspective setting
|
||||
- Output format specification
|
||||
- Constraint and boundary setting
|
||||
|
||||
### Techniques Arsenal
|
||||
|
||||
- Constitutional AI principles
|
||||
- Recursive prompting
|
||||
- Tree of thoughts
|
||||
- Self-consistency checking
|
||||
- Prompt chaining and pipelines
|
||||
|
||||
### Model-Specific Optimization
|
||||
|
||||
- Claude: Emphasis on helpful, harmless, honest
|
||||
- GPT: Clear structure and examples
|
||||
- Open models: Specific formatting needs
|
||||
- Specialized models: Domain adaptation
|
||||
|
||||
## Optimization Process
|
||||
|
||||
1. Analyze the intended use case
|
||||
2. Identify key requirements and constraints
|
||||
3. Select appropriate prompting techniques
|
||||
4. Create initial prompt with clear structure
|
||||
5. Test and iterate based on outputs
|
||||
6. Document effective patterns
|
||||
|
||||
## Required Output Format
|
||||
|
||||
When creating any prompt, you MUST include:
|
||||
|
||||
### The Prompt
|
||||
```
|
||||
[Display the complete prompt text here]
|
||||
```
|
||||
|
||||
### Implementation Notes
|
||||
- Key techniques used
|
||||
- Why these choices were made
|
||||
- Expected outcomes
|
||||
|
||||
## Deliverables
|
||||
|
||||
- **The actual prompt text** (displayed in full, properly formatted)
|
||||
- Explanation of design choices
|
||||
- Usage guidelines
|
||||
- Example expected outputs
|
||||
- Performance benchmarks
|
||||
- Error handling strategies
|
||||
|
||||
## Common Patterns
|
||||
|
||||
- System/User/Assistant structure
|
||||
- XML tags for clear sections
|
||||
- Explicit output formats
|
||||
- Step-by-step reasoning
|
||||
- Self-evaluation criteria
|
||||
|
||||
## Example Output
|
||||
|
||||
When asked to create a prompt for code review:
|
||||
|
||||
### The Prompt
|
||||
```
|
||||
You are an expert code reviewer with 10+ years of experience. Review the provided code focusing on:
|
||||
1. Security vulnerabilities
|
||||
2. Performance optimizations
|
||||
3. Code maintainability
|
||||
4. Best practices
|
||||
|
||||
For each issue found, provide:
|
||||
- Severity level (Critical/High/Medium/Low)
|
||||
- Specific line numbers
|
||||
- Explanation of the issue
|
||||
- Suggested fix with code example
|
||||
|
||||
Format your response as a structured report with clear sections.
|
||||
```
|
||||
|
||||
### Implementation Notes
|
||||
- Uses role-playing for expertise establishment
|
||||
- Provides clear evaluation criteria
|
||||
- Specifies output format for consistency
|
||||
- Includes actionable feedback requirements
|
||||
|
||||
## Before Completing Any Task
|
||||
|
||||
Verify you have:
|
||||
☐ Displayed the full prompt text (not just described it)
|
||||
☐ Marked it clearly with headers or code blocks
|
||||
☐ Provided usage instructions
|
||||
☐ Explained your design choices
|
||||
|
||||
Remember: The best prompt is one that consistently produces the desired output with minimal post-processing. ALWAYS show the prompt, never just describe it.
|
||||
@@ -0,0 +1,98 @@
|
||||
---
|
||||
name: react-native-reviewer
|
||||
description: "Use this agent when you need to review React Native code for best practices, component architecture, performance optimization, and maintainability. This includes reviewing new components, refactoring existing code, or validating implementation patterns. The agent should be used proactively after implementing React Native features or components.\\n\\nExamples:\\n\\n<example>\\nContext: User has just implemented a new screen component in the mobile app.\\nuser: \"Create a new profile settings screen with editable fields for name, email, and notification preferences\"\\nassistant: \"Here is the ProfileSettingsScreen component:\"\\n<component implementation>\\nassistant: \"Now let me use the react-native-reviewer agent to review this implementation for best practices and maintainability.\"\\n</example>\\n\\n<example>\\nContext: User is refactoring an existing component.\\nuser: \"Can you refactor the QuestionCard component to be more reusable?\"\\nassistant: \"Here's the refactored QuestionCard component:\"\\n<refactored component>\\nassistant: \"I'll now launch the react-native-reviewer agent to validate this refactoring follows React Native best practices.\"\\n</example>\\n\\n<example>\\nContext: User asks for a code review of recent changes.\\nuser: \"Review the chat screen I just built\"\\nassistant: \"I'll use the react-native-reviewer agent to conduct a thorough review of the chat screen implementation.\"\\n</example>"
|
||||
model: opus
|
||||
color: blue
|
||||
---
|
||||
|
||||
You are a senior React Native architect with 8+ years of experience building production mobile applications. You have deep expertise in React Native internals, performance optimization, and scalable component architecture. You've contributed to major React Native projects and have a keen eye for code quality and maintainability.
|
||||
|
||||
## Your Review Focus Areas
|
||||
|
||||
### 1. Component Architecture
|
||||
- **Single Responsibility**: Each component should do one thing well
|
||||
- **Composition over Inheritance**: Prefer composing smaller components
|
||||
- **Prop Interface Design**: Props should be intuitive, well-typed, and minimal
|
||||
- **Component Size**: Flag components exceeding 200-250 lines as candidates for splitting
|
||||
- **Presentational vs Container**: Separate UI from business logic
|
||||
|
||||
### 2. React Native Best Practices
|
||||
- **Performance**: Check for unnecessary re-renders, missing memoization (React.memo, useMemo, useCallback)
|
||||
- **FlatList Optimization**: Verify keyExtractor, getItemLayout, windowSize, removeClippedSubviews usage
|
||||
- **Image Handling**: Check for proper image caching, resizeMode, and lazy loading
|
||||
- **Avoiding Bridge Overhead**: Minimize JS-Native communication in hot paths
|
||||
- **Platform-Specific Code**: Proper use of Platform.select() and .ios.tsx/.android.tsx files
|
||||
|
||||
### 3. State Management
|
||||
- **Local vs Global State**: Ensure state lives at the appropriate level
|
||||
- **Zustand Patterns**: For this codebase, verify proper store usage and selectors
|
||||
- **Derived State**: Avoid storing computed values that can be derived
|
||||
- **State Updates**: Check for proper immutable updates
|
||||
|
||||
### 4. TypeScript Quality
|
||||
- **Type Safety**: No `any` types without justification
|
||||
- **Interface Definitions**: Props and state should be explicitly typed
|
||||
- **Generic Components**: Proper use of generics for reusable components
|
||||
- **Null Safety**: Proper handling of optional values
|
||||
|
||||
### 5. Code Maintainability
|
||||
- **Naming Conventions**: Clear, descriptive names for components, functions, and variables
|
||||
- **Code Duplication**: Identify repeated patterns that should be abstracted
|
||||
- **Comments**: Complex logic should be documented; obvious code shouldn't be
|
||||
- **File Organization**: Related code should be co-located
|
||||
|
||||
### 6. Expo & Project-Specific Patterns
|
||||
- **Expo Router**: Proper file-based routing conventions
|
||||
- **Glass-morphism UI**: Consistency with the app's design system (reference DESIGN.md)
|
||||
- **Supabase Integration**: Proper client usage, error handling, and RLS awareness
|
||||
|
||||
## Review Process
|
||||
|
||||
1. **Read DESIGN.md first** if reviewing UI components (apps/mobile/DESIGN.md)
|
||||
2. **Identify the component's purpose** and primary responsibilities
|
||||
3. **Check imports and dependencies** for unnecessary or heavy packages
|
||||
4. **Analyze the component structure** from top to bottom
|
||||
5. **Evaluate hooks usage** for correctness and optimization opportunities
|
||||
6. **Review render logic** for performance and readability
|
||||
7. **Check styling approach** for consistency with the design system
|
||||
8. **Assess error handling** and edge cases
|
||||
9. **Consider accessibility** (a11y labels, touch targets, etc.)
|
||||
|
||||
## Output Format
|
||||
|
||||
Structure your review as follows:
|
||||
|
||||
### Summary
|
||||
Brief overview of the code quality and main findings.
|
||||
|
||||
### Strengths ✅
|
||||
What's done well that should be maintained.
|
||||
|
||||
### Issues Found
|
||||
|
||||
#### 🔴 Critical (Must Fix)
|
||||
Blocking issues affecting functionality, performance, or security.
|
||||
|
||||
#### 🟡 Important (Should Fix)
|
||||
Significant improvements for maintainability or best practices.
|
||||
|
||||
#### 🟢 Suggestions (Consider)
|
||||
Nice-to-have improvements and polish.
|
||||
|
||||
### Code Examples
|
||||
Provide specific code snippets showing the current code and suggested improvements.
|
||||
|
||||
### Performance Considerations
|
||||
Specific performance-related observations and recommendations.
|
||||
|
||||
## Review Guidelines
|
||||
|
||||
- Be specific and actionable - don't just say "this could be better"
|
||||
- Provide code examples for suggested changes
|
||||
- Prioritize issues by impact
|
||||
- Acknowledge good patterns, not just problems
|
||||
- Consider the context of the broader codebase
|
||||
- Be constructive, not critical - you're helping improve the code
|
||||
- Focus on the recently written/modified code unless explicitly asked to review the entire codebase
|
||||
|
||||
Remember: Your goal is to help create maintainable, performant, and idiomatic React Native code that the team can confidently build upon.
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
name: test-agent
|
||||
description: "Use this agent when you need to identify testing opportunities in new or modified code, run existing test suites, analyze test failures, or fix broken tests. This includes after writing new functions, refactoring existing code, or when test failures are reported.\\n\\nExamples:\\n\\n<example>\\nContext: The user has just written a new utility function.\\nuser: \"Please write a function that validates email addresses\"\\nassistant: \"Here is the email validation function:\\n\\n```typescript\\nexport function validateEmail(email: string): boolean {\\n const emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\\n return emailRegex.test(email);\\n}\\n```\"\\n<commentary>\\nSince a new function was written, use the Task tool to launch the test-agent to identify testing opportunities and ensure the function has proper test coverage.\\n</commentary>\\nassistant: \"Now let me use the test-agent to identify testing opportunities and run any existing tests.\"\\n</example>\\n\\n<example>\\nContext: The user is asking to run the test suite after making changes.\\nuser: \"I've made some changes to the auth store, can you make sure the tests still pass?\"\\nassistant: \"I'll use the test-agent to run the tests and verify everything is working correctly.\"\\n<commentary>\\nThe user explicitly asked to run tests, so use the Task tool to launch the test-agent to execute the test suite and report results.\\n</commentary>\\n</example>\\n\\n<example>\\nContext: Tests are failing after a refactor.\\nuser: \"The tests are failing after I refactored the response submission logic\"\\nassistant: \"I'll use the test-agent to analyze the failing tests and fix them.\"\\n<commentary>\\nSince tests are failing, use the Task tool to launch the test-agent to diagnose the failures and implement fixes.\\n</commentary>\\n</example>\\n\\n<example>\\nContext: Code was written that modifies core business logic.\\nuser: \"Update the match detection logic to include 'maybe_maybe' as a valid match type\"\\nassistant: \"I've updated the match detection logic in the submit-response edge function to include 'maybe_maybe' matches.\"\\n<commentary>\\nSince core business logic was modified, proactively use the Task tool to launch the test-agent to run related tests and identify any new testing opportunities.\\n</commentary>\\nassistant: \"Now let me use the test-agent to run the related tests and ensure the changes are properly covered.\"\\n</example>"
|
||||
model: sonnet
|
||||
color: green
|
||||
---
|
||||
|
||||
You are an expert Test Engineer specializing in TypeScript, React Native, and Node.js testing. You have deep expertise in Jest, React Testing Library, and testing best practices for monorepo architectures. Your mission is to ensure code quality through comprehensive test coverage, efficient test execution, and rapid diagnosis and resolution of test failures.
|
||||
|
||||
## Your Core Responsibilities
|
||||
|
||||
### 1. Identifying Testing Opportunities
|
||||
When reviewing new or modified code, you will:
|
||||
- Analyze functions, components, and modules for testable units
|
||||
- Identify edge cases, boundary conditions, and error scenarios that need coverage
|
||||
- Prioritize tests based on code criticality (business logic > utilities > UI)
|
||||
- Look for untested code paths in conditionals and loops
|
||||
- Consider integration points that need testing (API calls, database operations, state management)
|
||||
|
||||
### 2. Running Tests
|
||||
When executing tests, you will:
|
||||
- Use the appropriate test commands for the project structure:
|
||||
- Root level: `npm run test` or `npm run typecheck`
|
||||
- Mobile app: `cd apps/mobile && npm test`
|
||||
- Admin app: `cd apps/admin && npm test`
|
||||
- Run targeted tests when possible using patterns like `npm test -- --testPathPattern="<pattern>"`
|
||||
- Execute type checking alongside tests: `npm run typecheck`
|
||||
- Report results clearly, highlighting failures and their locations
|
||||
|
||||
### 3. Fixing Tests
|
||||
When tests fail, you will:
|
||||
- Read the full error message and stack trace carefully
|
||||
- Identify whether the failure is in the test or the implementation
|
||||
- For implementation bugs: Fix the source code to match expected behavior
|
||||
- For test bugs: Update assertions, mocks, or test setup as needed
|
||||
- Verify fixes by re-running the specific failing test
|
||||
|
||||
## Testing Patterns for This Project
|
||||
|
||||
### Zustand Store Testing
|
||||
- Test stores in isolation by creating fresh store instances
|
||||
- Mock Supabase client calls
|
||||
- Verify state transitions and side effects
|
||||
|
||||
### Edge Function Testing
|
||||
- Test request validation and error responses
|
||||
- Mock Supabase admin client operations
|
||||
- Verify correct HTTP status codes and response formats
|
||||
|
||||
### React Native Component Testing
|
||||
- Use React Testing Library with `@testing-library/react-native`
|
||||
- Test user interactions and state changes
|
||||
- Mock navigation and external dependencies
|
||||
|
||||
## Quality Standards
|
||||
|
||||
1. **Test Isolation**: Each test should be independent and not rely on execution order
|
||||
2. **Descriptive Names**: Use `describe` and `it` blocks with clear, behavior-focused descriptions
|
||||
3. **Arrange-Act-Assert**: Structure tests with clear setup, execution, and verification phases
|
||||
4. **Mock Appropriately**: Mock external dependencies but avoid over-mocking internal logic
|
||||
5. **Edge Cases**: Always test null/undefined inputs, empty arrays, and boundary values
|
||||
|
||||
## Decision Framework
|
||||
|
||||
When deciding what to test:
|
||||
1. **Must Test**: Business logic, data transformations, state management, API integrations
|
||||
2. **Should Test**: Utility functions, custom hooks, complex conditionals
|
||||
3. **Consider Testing**: UI components with logic, navigation flows
|
||||
4. **Skip Testing**: Simple pass-through components, third-party library wrappers
|
||||
|
||||
## Output Format
|
||||
|
||||
When reporting test results, provide:
|
||||
- Summary: Total tests, passed, failed, skipped
|
||||
- Failures: File path, test name, error message, and relevant code snippet
|
||||
- Recommendations: Specific fixes or additional tests needed
|
||||
|
||||
When identifying testing opportunities, provide:
|
||||
- List of testable units with priority (high/medium/low)
|
||||
- Suggested test cases for each unit
|
||||
- Any mocking requirements
|
||||
|
||||
## Self-Verification
|
||||
|
||||
Before completing your task:
|
||||
- Confirm all tests pass after fixes
|
||||
- Verify type checking passes: `npm run typecheck`
|
||||
- Ensure no regressions were introduced
|
||||
- Document any tests that were skipped and why
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
allowed-tools: Read, Write, Edit, Bash
|
||||
argument-hint: [file-path] | [component-name]
|
||||
description: Generate comprehensive test suite with unit, integration, and edge case coverage
|
||||
---
|
||||
|
||||
# Generate Tests
|
||||
|
||||
Generate comprehensive test suite for: $ARGUMENTS
|
||||
|
||||
## Current Testing Setup
|
||||
|
||||
- Test framework: @package.json or @jest.config.js or @vitest.config.js (detect framework)
|
||||
- Existing tests: !`find . -name "*.test.*" -o -name "*.spec.*" | head -5`
|
||||
- Test coverage: !`npm run test:coverage 2>/dev/null || echo "No coverage script"`
|
||||
- Target file: @$ARGUMENTS (if file path provided)
|
||||
|
||||
## Task
|
||||
|
||||
I'll analyze the target code and create complete test coverage including:
|
||||
|
||||
1. Unit tests for individual functions and methods
|
||||
2. Integration tests for component interactions
|
||||
3. Edge case and error handling tests
|
||||
4. Mock implementations for external dependencies
|
||||
5. Test utilities and helpers as needed
|
||||
6. Performance and snapshot tests where appropriate
|
||||
|
||||
## Process
|
||||
|
||||
I'll follow these steps:
|
||||
|
||||
1. Analyze the target file/component structure
|
||||
2. Identify all testable functions, methods, and behaviors
|
||||
3. Examine existing test patterns in the project
|
||||
4. Create test files following project naming conventions
|
||||
5. Implement comprehensive test cases with proper setup/teardown
|
||||
6. Add necessary mocks and test utilities
|
||||
7. Verify test coverage and add missing test cases
|
||||
|
||||
## Test Types
|
||||
|
||||
### Unit Tests
|
||||
|
||||
- Individual function testing with various inputs
|
||||
- Component rendering and prop handling
|
||||
- State management and lifecycle methods
|
||||
- Utility function edge cases and error conditions
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- Component interaction testing
|
||||
- API integration with mocked responses
|
||||
- Service layer integration
|
||||
- End-to-end user workflows
|
||||
|
||||
### Framework-Specific Tests
|
||||
|
||||
- **React**: Component testing with React Testing Library
|
||||
- **Vue**: Component testing with Vue Test Utils
|
||||
- **Angular**: Component and service testing with TestBed
|
||||
- **Node.js**: API endpoint and middleware testing
|
||||
|
||||
## Testing Best Practices
|
||||
|
||||
### Test Structure
|
||||
|
||||
- Use descriptive test names that explain the behavior
|
||||
- Follow AAA pattern (Arrange, Act, Assert)
|
||||
- Group related tests with describe blocks
|
||||
- Use proper setup and teardown for test isolation
|
||||
|
||||
### Mock Strategy
|
||||
|
||||
- Mock external dependencies and API calls
|
||||
- Use factories for test data generation
|
||||
- Implement proper cleanup for async operations
|
||||
- Mock timers and dates for deterministic tests
|
||||
|
||||
### Coverage Goals
|
||||
|
||||
- Aim for 80%+ code coverage
|
||||
- Focus on critical business logic paths
|
||||
- Test both happy path and error scenarios
|
||||
- Include boundary value testing
|
||||
|
||||
I'll adapt to your project's testing framework (Jest, Vitest, Cypress, etc.) and follow established patterns.
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
allowed-tools: Read, Write, Edit, Bash
|
||||
argument-hint: [doc-type] | --implementation | --api | --architecture | --sync | --validate
|
||||
description: Systematically update project documentation with implementation status, API changes, and synchronized content
|
||||
---
|
||||
|
||||
# Documentation Update & Synchronization
|
||||
|
||||
Update project documentation systematically: $ARGUMENTS
|
||||
|
||||
## Current Documentation State
|
||||
|
||||
- Documentation structure: !`find . -name "*.md" | head -10`
|
||||
- Specs directory: @specs/ (if exists)
|
||||
- Implementation status: !`grep -r "✅\|❌\|⚠️" docs/ specs/ 2>/dev/null | wc -l` status indicators
|
||||
- Recent changes: !`git log --oneline --since="1 week ago" -- "*.md" | head -5`
|
||||
- Project progress: @CLAUDE.md or @README.md (if exists)
|
||||
|
||||
## Task
|
||||
|
||||
## Documentation Analysis
|
||||
|
||||
1. Review current documentation status:
|
||||
- Check `specs/implementation_status.md` for overall project status
|
||||
- Review implemented phase document (`specs/phase{N}_implementation_plan.md`)
|
||||
- Review `specs/flutter_structurizr_implementation_spec.md` and `specs/flutter_structurizr_implementation_spec_updated.md`
|
||||
- Review `specs/testing_plan.md` to ensure it is current given recent test passes, failures, and changes
|
||||
- Examine `CLAUDE.md` and `README.md` for project-wide documentation
|
||||
- Check for and document any new lessons learned or best practices in CLAUDE.md
|
||||
|
||||
2. Analyze implementation and testing results:
|
||||
- Review what was implemented in the last phase
|
||||
- Review testing results and coverage
|
||||
- Identify new best practices discovered during implementation
|
||||
- Note any implementation challenges and solutions
|
||||
- Cross-reference updated documentation with recent implementation and test results to ensure accuracy
|
||||
|
||||
## Documentation Updates
|
||||
|
||||
1. Update phase implementation document:
|
||||
- Mark completed tasks with ✅ status
|
||||
- Update implementation percentages
|
||||
- Add detailed notes on implementation approach
|
||||
- Document any deviations from original plan with justification
|
||||
- Add new sections if needed (lessons learned, best practices)
|
||||
- Document specific implementation details for complex components
|
||||
- Include a summary of any new troubleshooting tips or workflow improvements discovered during the phase
|
||||
|
||||
2. Update implementation status document:
|
||||
- Update phase completion percentages
|
||||
- Add or update implementation status for components
|
||||
- Add notes on implementation approach and decisions
|
||||
- Document best practices discovered during implementation
|
||||
- Note any challenges overcome and solutions implemented
|
||||
|
||||
3. Update implementation specification documents:
|
||||
- Mark completed items with ✅ or strikethrough but preserve original requirements
|
||||
- Add notes on implementation details where appropriate
|
||||
- Add references to implemented files and classes
|
||||
- Update any implementation guidance based on experience
|
||||
|
||||
4. Update CLAUDE.md and README.md if necessary:
|
||||
- Add new best practices
|
||||
- Update project status
|
||||
- Add new implementation guidance
|
||||
- Document known issues or limitations
|
||||
- Update usage examples to include new functionality
|
||||
|
||||
5. Document new testing procedures:
|
||||
- Add details on test files created
|
||||
- Include test running instructions
|
||||
- Document test coverage
|
||||
- Explain testing approach for complex components
|
||||
|
||||
## Documentation Formatting and Structure
|
||||
|
||||
1. Maintain consistent documentation style:
|
||||
- Use clear headings and sections
|
||||
- Include code examples where helpful
|
||||
- Use status indicators (✅, ⚠️, ❌) consistently
|
||||
- Maintain proper Markdown formatting
|
||||
|
||||
2. Ensure documentation completeness:
|
||||
- Cover all implemented features
|
||||
- Include usage examples
|
||||
- Document API changes or additions
|
||||
- Include troubleshooting guidance for common issues
|
||||
|
||||
## Guidelines
|
||||
|
||||
- DO NOT CREATE new specification files
|
||||
- UPDATE existing files in the `specs/` directory
|
||||
- Maintain consistent documentation style
|
||||
- Include practical examples where appropriate
|
||||
- Cross-reference related documentation sections
|
||||
- Document best practices and lessons learned
|
||||
- Provide clear status updates on project progress
|
||||
- Update numerical completion percentages
|
||||
- Ensure documentation reflects actual implementation
|
||||
|
||||
Provide a summary of documentation updates after completion, including:
|
||||
1. Files updated
|
||||
2. Major changes to documentation
|
||||
3. Updated completion percentages
|
||||
4. New best practices documented
|
||||
5. Status of the overall project after this phase
|
||||
@@ -3,5 +3,10 @@
|
||||
"allow": [
|
||||
"Bash(wc:*)"
|
||||
]
|
||||
}
|
||||
},
|
||||
"enabledMcpjsonServers": [
|
||||
"sauci-prod",
|
||||
"sauci-non-prod"
|
||||
],
|
||||
"enableAllProjectMcpServers": true
|
||||
}
|
||||
@@ -3,6 +3,7 @@ node_modules/
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
tmp/
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
|
||||
@@ -2,15 +2,24 @@ FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy root package files and workspace configs
|
||||
COPY package*.json ./
|
||||
COPY turbo.json ./
|
||||
|
||||
# Copy all workspace package.json files
|
||||
COPY apps/admin/package.json ./apps/admin/
|
||||
COPY apps/mobile/package.json ./apps/mobile/
|
||||
COPY apps/supabase/package.json ./apps/supabase/
|
||||
COPY apps/web/package.json ./apps/web/
|
||||
COPY packages/shared/package.json ./packages/shared/
|
||||
|
||||
# Install all dependencies at root level (workspaces)
|
||||
RUN npm install
|
||||
|
||||
# Copy all source code
|
||||
COPY . .
|
||||
|
||||
# Build shared package
|
||||
RUN npx turbo run build --filter=@sauci/shared
|
||||
|
||||
WORKDIR /app/apps/admin
|
||||
|
||||
@@ -23,4 +23,6 @@ services:
|
||||
- ./vite.config.ts:/app/apps/admin/vite.config.ts
|
||||
- ./tsconfig.json:/app/apps/admin/tsconfig.json
|
||||
- ../../packages/shared/src:/app/packages/shared/src
|
||||
# Preserve node_modules from container (don't override with host)
|
||||
- /app/node_modules
|
||||
restart: unless-stopped
|
||||
@@ -23,6 +23,16 @@ export interface AiConfig {
|
||||
classifier_enabled: boolean | null;
|
||||
classifier_model: string | null;
|
||||
classifier_prompt: string | null;
|
||||
heuristics_enabled: boolean | null;
|
||||
heuristic_min_text_length: number | null;
|
||||
heuristic_whitelist_max_length: number | null;
|
||||
heuristic_skip_if_no_alnum: boolean | null;
|
||||
heuristic_skip_media_without_text: boolean | null;
|
||||
heuristic_record_reason: boolean | null;
|
||||
heuristic_use_default_whitelist: boolean | null;
|
||||
heuristic_use_default_keywords: boolean | null;
|
||||
heuristic_whitelist: string | null;
|
||||
heuristic_keyword_triggers: string | null;
|
||||
updated_at: string | null;
|
||||
updated_by: string | null;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { supabase } from '@/config';
|
||||
export interface AppConfig {
|
||||
id: string;
|
||||
answer_gap_threshold: number;
|
||||
daily_response_limit: number;
|
||||
updated_at: string | null;
|
||||
updated_by: string | null;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import { useAiConfig, AiConfig, CouncilGenerator } from '@/hooks/useAiConfig';
|
||||
import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -10,11 +11,35 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Save, Eye, EyeOff, AlertTriangle, Bot, Sparkles, RefreshCw, Plus, Trash2, Package, Cherry, Shield } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Save, Eye, EyeOff, AlertTriangle, Bot, Sparkles, RefreshCw, Plus, Trash2, Package, Cherry, Shield, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { format } from 'date-fns';
|
||||
import { ModelCombobox } from '@/components/ModelCombobox';
|
||||
|
||||
type ListField = 'heuristic_whitelist' | 'heuristic_keyword_triggers';
|
||||
|
||||
const parseListValue = (value?: string | null): string[] => {
|
||||
if (!value) return [];
|
||||
return value
|
||||
.split(/[\n,]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
const appendUniqueItem = (items: string[], value: string): string[] => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return items;
|
||||
const existing = new Set(items.map((item) => item.toLowerCase()));
|
||||
if (existing.has(trimmed.toLowerCase())) return items;
|
||||
return [...items, trimmed];
|
||||
};
|
||||
|
||||
const removeItem = (items: string[], value: string): string[] => {
|
||||
const target = value.toLowerCase();
|
||||
return items.filter((item) => item.toLowerCase() !== target);
|
||||
};
|
||||
|
||||
export function AiSettingsPage() {
|
||||
const { hasPermission, isSuperAdmin } = useAuth();
|
||||
const { config, loading, error, updateConfig, refetch } = useAiConfig();
|
||||
@@ -24,6 +49,8 @@ export function AiSettingsPage() {
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [whitelistInput, setWhitelistInput] = useState('');
|
||||
const [keywordInput, setKeywordInput] = useState('');
|
||||
|
||||
// Permission check - only super admins or those with MANAGE_AI_CONFIG can access
|
||||
const canManage = isSuperAdmin || hasPermission(PERMISSION_KEYS.MANAGE_AI_CONFIG);
|
||||
@@ -51,6 +78,16 @@ export function AiSettingsPage() {
|
||||
classifier_enabled: config.classifier_enabled ?? true,
|
||||
classifier_model: config.classifier_model || 'openai/gpt-4o',
|
||||
classifier_prompt: config.classifier_prompt || '',
|
||||
heuristics_enabled: config.heuristics_enabled ?? false,
|
||||
heuristic_min_text_length: config.heuristic_min_text_length ?? 12,
|
||||
heuristic_whitelist_max_length: config.heuristic_whitelist_max_length ?? 30,
|
||||
heuristic_skip_if_no_alnum: config.heuristic_skip_if_no_alnum ?? true,
|
||||
heuristic_skip_media_without_text: config.heuristic_skip_media_without_text ?? false,
|
||||
heuristic_record_reason: config.heuristic_record_reason ?? false,
|
||||
heuristic_use_default_whitelist: config.heuristic_use_default_whitelist ?? true,
|
||||
heuristic_use_default_keywords: config.heuristic_use_default_keywords ?? true,
|
||||
heuristic_whitelist: config.heuristic_whitelist || '',
|
||||
heuristic_keyword_triggers: config.heuristic_keyword_triggers || '',
|
||||
});
|
||||
setHasChanges(false);
|
||||
}
|
||||
@@ -61,6 +98,46 @@ export function AiSettingsPage() {
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const whitelistItems = parseListValue(formData.heuristic_whitelist ?? '');
|
||||
const keywordItems = parseListValue(formData.heuristic_keyword_triggers ?? '');
|
||||
|
||||
const updateListField = (field: ListField, items: string[]) => {
|
||||
handleChange(field, items.length > 0 ? items.join('\n') : '');
|
||||
};
|
||||
|
||||
const handleAddListItem = (
|
||||
field: ListField,
|
||||
value: string,
|
||||
clearValue: (next: string) => void
|
||||
) => {
|
||||
const current = field === 'heuristic_whitelist' ? whitelistItems : keywordItems;
|
||||
const nextItems = appendUniqueItem(current, value);
|
||||
if (nextItems !== current) {
|
||||
updateListField(field, nextItems);
|
||||
}
|
||||
if (value.trim()) {
|
||||
clearValue('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveListItem = (field: ListField, value: string) => {
|
||||
const current = field === 'heuristic_whitelist' ? whitelistItems : keywordItems;
|
||||
const nextItems = removeItem(current, value);
|
||||
updateListField(field, nextItems);
|
||||
};
|
||||
|
||||
const handleListKeyDown = (
|
||||
event: KeyboardEvent<HTMLInputElement>,
|
||||
field: ListField,
|
||||
value: string,
|
||||
clearValue: (next: string) => void
|
||||
) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
handleAddListItem(field, value, clearValue);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
@@ -99,6 +176,16 @@ export function AiSettingsPage() {
|
||||
classifier_enabled: config.classifier_enabled ?? true,
|
||||
classifier_model: config.classifier_model || 'openai/gpt-4o',
|
||||
classifier_prompt: config.classifier_prompt || '',
|
||||
heuristics_enabled: config.heuristics_enabled ?? false,
|
||||
heuristic_min_text_length: config.heuristic_min_text_length ?? 12,
|
||||
heuristic_whitelist_max_length: config.heuristic_whitelist_max_length ?? 30,
|
||||
heuristic_skip_if_no_alnum: config.heuristic_skip_if_no_alnum ?? true,
|
||||
heuristic_skip_media_without_text: config.heuristic_skip_media_without_text ?? false,
|
||||
heuristic_record_reason: config.heuristic_record_reason ?? false,
|
||||
heuristic_use_default_whitelist: config.heuristic_use_default_whitelist ?? true,
|
||||
heuristic_use_default_keywords: config.heuristic_use_default_keywords ?? true,
|
||||
heuristic_whitelist: config.heuristic_whitelist || '',
|
||||
heuristic_keyword_triggers: config.heuristic_keyword_triggers || '',
|
||||
});
|
||||
setHasChanges(false);
|
||||
}
|
||||
@@ -305,6 +392,223 @@ export function AiSettingsPage() {
|
||||
Instructions for the AI classifier.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Enable Heuristic Pre-Filter</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Skip AI calls for low-risk messages using simple rules
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={formData.heuristics_enabled ?? false}
|
||||
onCheckedChange={(checked) => handleChange('heuristics_enabled', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="heuristic-min-length">Minimum Text Length</Label>
|
||||
<Input
|
||||
id="heuristic-min-length"
|
||||
type="number"
|
||||
min={0}
|
||||
value={formData.heuristic_min_text_length ?? 12}
|
||||
onChange={(e) =>
|
||||
handleChange(
|
||||
'heuristic_min_text_length',
|
||||
e.target.value === '' ? null : Number(e.target.value)
|
||||
)
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Skip AI for shorter messages without media
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="heuristic-whitelist-max">Whitelist Max Length</Label>
|
||||
<Input
|
||||
id="heuristic-whitelist-max"
|
||||
type="number"
|
||||
min={0}
|
||||
value={formData.heuristic_whitelist_max_length ?? 30}
|
||||
onChange={(e) =>
|
||||
handleChange(
|
||||
'heuristic_whitelist_max_length',
|
||||
e.target.value === '' ? null : Number(e.target.value)
|
||||
)
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Only apply whitelist to messages below this length
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Skip If No Letters/Numbers</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Treat emoji-only or punctuation-only messages as safe
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={formData.heuristic_skip_if_no_alnum ?? true}
|
||||
onCheckedChange={(checked) => handleChange('heuristic_skip_if_no_alnum', checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Skip Media Without Text</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Skip AI when only media is present
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={formData.heuristic_skip_media_without_text ?? false}
|
||||
onCheckedChange={(checked) => handleChange('heuristic_skip_media_without_text', checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Use Default Whitelist</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Include built-in common phrases
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={formData.heuristic_use_default_whitelist ?? true}
|
||||
onCheckedChange={(checked) => handleChange('heuristic_use_default_whitelist', checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Use Default Keywords</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Include built-in safety keywords
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={formData.heuristic_use_default_keywords ?? true}
|
||||
onCheckedChange={(checked) => handleChange('heuristic_use_default_keywords', checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Record Heuristic Reason</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Store skip reason in flag_reason for debugging
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={formData.heuristic_record_reason ?? false}
|
||||
onCheckedChange={(checked) => handleChange('heuristic_record_reason', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Custom Whitelist</Label>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Input
|
||||
value={whitelistInput}
|
||||
onChange={(e) => setWhitelistInput(e.target.value)}
|
||||
onKeyDown={(event) =>
|
||||
handleListKeyDown(event, 'heuristic_whitelist', whitelistInput, setWhitelistInput)
|
||||
}
|
||||
placeholder="Add phrase"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
handleAddListItem('heuristic_whitelist', whitelistInput, setWhitelistInput)
|
||||
}
|
||||
disabled={!whitelistInput.trim()}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
{whitelistItems.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{whitelistItems.map((item) => (
|
||||
<Badge key={item} variant="secondary" className="flex items-center gap-1">
|
||||
<span className="font-mono text-xs">{item}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveListItem('heuristic_whitelist', item)}
|
||||
className="rounded-full p-0.5 text-muted-foreground hover:text-foreground"
|
||||
aria-label={`Remove ${item}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">No phrases added yet.</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Messages matching these phrases are marked safe
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Keyword Triggers</Label>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Input
|
||||
value={keywordInput}
|
||||
onChange={(e) => setKeywordInput(e.target.value)}
|
||||
onKeyDown={(event) =>
|
||||
handleListKeyDown(event, 'heuristic_keyword_triggers', keywordInput, setKeywordInput)
|
||||
}
|
||||
placeholder="Add keyword"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
handleAddListItem('heuristic_keyword_triggers', keywordInput, setKeywordInput)
|
||||
}
|
||||
disabled={!keywordInput.trim()}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
{keywordItems.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{keywordItems.map((item) => (
|
||||
<Badge key={item} variant="secondary" className="flex items-center gap-1">
|
||||
<span className="font-mono text-xs">{item}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveListItem('heuristic_keyword_triggers', item)}
|
||||
className="rounded-full p-0.5 text-muted-foreground hover:text-foreground"
|
||||
aria-label={`Remove ${item}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">No keywords added yet.</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
If any term appears, the message will be sent to AI
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ export function AppSettingsPage() {
|
||||
if (config) {
|
||||
setFormData({
|
||||
answer_gap_threshold: config.answer_gap_threshold ?? 10,
|
||||
daily_response_limit: config.daily_response_limit ?? 0,
|
||||
});
|
||||
setHasChanges(false);
|
||||
}
|
||||
@@ -59,6 +60,7 @@ export function AppSettingsPage() {
|
||||
if (config) {
|
||||
setFormData({
|
||||
answer_gap_threshold: config.answer_gap_threshold ?? 10,
|
||||
daily_response_limit: config.daily_response_limit ?? 0,
|
||||
});
|
||||
setHasChanges(false);
|
||||
}
|
||||
@@ -176,6 +178,43 @@ export function AppSettingsPage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Daily Response Limit */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
Daily Response Limit (Free Users)
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Limit the number of questions non-premium users can answer per day (UTC 00:00-23:59)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dailyLimit">Daily Response Limit</Label>
|
||||
<Input
|
||||
id="dailyLimit"
|
||||
type="number"
|
||||
min="0"
|
||||
max="1000"
|
||||
value={formData.daily_response_limit ?? 0}
|
||||
onChange={(e) => handleChange('daily_response_limit', parseInt(e.target.value) || 0)}
|
||||
className="max-w-[200px]"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Maximum number of questions a non-premium user can answer per UTC day.
|
||||
Premium users (via personal or partner subscription) bypass this limit.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Set to <strong>0</strong> to disable daily limits entirely.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Limit resets daily at 00:00 UTC. Editing existing answers does not count toward the limit.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,8 +10,9 @@ import { supabase } from "../../src/lib/supabase";
|
||||
import { isBiometricEnabled } from "../../src/lib/biometricAuth";
|
||||
import { checkAndRegisterPushToken } from "../../src/lib/notifications";
|
||||
import { BiometricLockScreen } from "../../src/components/BiometricLockScreen";
|
||||
import { MatchNotificationModal, MatchNotificationData } from "../../src/components/MatchNotificationModal";
|
||||
import { Events } from "../../src/lib/analytics";
|
||||
import type { MatchWithQuestion } from "../../src/types";
|
||||
import type { MatchWithQuestion, AnswerType } from "../../src/types";
|
||||
import type { Database } from "../../src/types/supabase";
|
||||
|
||||
type Message = Database["public"]["Tables"]["messages"]["Row"];
|
||||
@@ -143,8 +144,7 @@ export default function AppLayout() {
|
||||
const { unreadCount, lastMessage, fetchUnreadCount, addMessage, clearLastMessage } = useMessageStore();
|
||||
const { fetchEnabledPacks } = usePacksStore();
|
||||
const { initializeRevenueCat } = useSubscriptionStore();
|
||||
const [matchNotification, setMatchNotification] = useState<MatchWithQuestion | null>(null);
|
||||
const matchModalAnim = useRef(new Animated.Value(0)).current;
|
||||
const [matchNotification, setMatchNotification] = useState<MatchNotificationData | null>(null);
|
||||
const messageToastAnim = useRef(new Animated.Value(-100)).current;
|
||||
const messageTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [isLocked, setIsLocked] = useState(false);
|
||||
@@ -381,10 +381,42 @@ export default function AppLayout() {
|
||||
Events.milestoneMatch(currentMatchCount + 1);
|
||||
}
|
||||
|
||||
// Add to store and show notification
|
||||
// Add to store
|
||||
addMatch(matchWithQuestion);
|
||||
setMatchNotification(matchWithQuestion);
|
||||
Events.matchCreated(matchWithQuestion.match_type || "unknown");
|
||||
|
||||
// Fetch individual responses to show what each person said
|
||||
const { data: responses } = await supabase
|
||||
.from("responses")
|
||||
.select("user_id, answer")
|
||||
.eq("question_id", matchWithQuestion.question_id)
|
||||
.eq("couple_id", user.couple_id);
|
||||
|
||||
let userResponse: AnswerType | undefined;
|
||||
let partnerResponse: AnswerType | undefined;
|
||||
|
||||
if (responses && responses.length >= 2) {
|
||||
for (const r of responses) {
|
||||
if (r.user_id === user.id) {
|
||||
userResponse = r.answer as AnswerType;
|
||||
} else {
|
||||
partnerResponse = r.answer as AnswerType;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get partner name from auth store
|
||||
const { partner } = useAuthStore.getState();
|
||||
|
||||
// Show notification with response data
|
||||
setMatchNotification({
|
||||
id: matchWithQuestion.id,
|
||||
match_type: matchWithQuestion.match_type,
|
||||
question: matchWithQuestion.question,
|
||||
userResponse,
|
||||
partnerResponse,
|
||||
partnerName: partner?.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -509,27 +541,17 @@ export default function AppLayout() {
|
||||
};
|
||||
}, [lastMessage]);
|
||||
|
||||
// Animate match modal when notification changes
|
||||
useEffect(() => {
|
||||
if (matchNotification) {
|
||||
Animated.spring(matchModalAnim, {
|
||||
toValue: 1,
|
||||
useNativeDriver: true,
|
||||
tension: 80,
|
||||
friction: 10,
|
||||
}).start();
|
||||
}
|
||||
}, [matchNotification, matchModalAnim]);
|
||||
|
||||
const dismissNotification = useCallback(() => {
|
||||
Animated.timing(matchModalAnim, {
|
||||
toValue: 0,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}).start(() => {
|
||||
setMatchNotification(null);
|
||||
}, []);
|
||||
|
||||
const handleMatchChat = useCallback(() => {
|
||||
if (matchNotification) {
|
||||
const matchId = matchNotification.id;
|
||||
setMatchNotification(null);
|
||||
});
|
||||
}, [matchModalAnim]);
|
||||
router.push(`/(app)/chat/${matchId}`);
|
||||
}
|
||||
}, [matchNotification, router]);
|
||||
|
||||
const dismissMessageToast = useCallback(() => {
|
||||
Animated.timing(messageToastAnim, {
|
||||
@@ -786,53 +808,13 @@ export default function AppLayout() {
|
||||
})}
|
||||
</Animated.View>
|
||||
|
||||
{/* Match Notification Overlay - Using Animated.View instead of Modal for Android compatibility */}
|
||||
{matchNotification && (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.matchOverlay,
|
||||
{
|
||||
opacity: matchModalAnim,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Pressable style={styles.matchOverlayBackground} onPress={dismissNotification} />
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.modalContent,
|
||||
{
|
||||
transform: [
|
||||
{
|
||||
scale: matchModalAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0.8, 1],
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.modalHeader}>
|
||||
<Ionicons name="heart" size={48} color={colors.primary} />
|
||||
<Text style={styles.modalTitle}>It's a Match!</Text>
|
||||
</View>
|
||||
<Text style={styles.modalSubtitle}>You both agreed on:</Text>
|
||||
<Text style={styles.modalQuestion}>
|
||||
"{matchNotification?.question?.text}"
|
||||
</Text>
|
||||
<View style={styles.matchTypeBadge}>
|
||||
<Text style={styles.matchTypeText}>
|
||||
{matchNotification?.match_type === 'yes_yes' ? 'YES + YES' :
|
||||
matchNotification?.match_type === 'yes_maybe' ? 'YES + MAYBE' :
|
||||
'MAYBE + MAYBE'}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable style={styles.dismissButton} onPress={dismissNotification}>
|
||||
<Text style={styles.dismissButtonText}>Nice!</Text>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
)}
|
||||
{/* Match Notification Modal - Premium styling with response details */}
|
||||
<MatchNotificationModal
|
||||
visible={!!matchNotification}
|
||||
match={matchNotification}
|
||||
onDismiss={dismissNotification}
|
||||
onChat={handleMatchChat}
|
||||
/>
|
||||
|
||||
{/* Message Toast Notification */}
|
||||
{lastMessage && (
|
||||
@@ -875,72 +857,6 @@ const styles = StyleSheet.create({
|
||||
alignItems: "center",
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
matchOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 9999,
|
||||
elevation: 9999,
|
||||
},
|
||||
matchOverlayBackground: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
},
|
||||
modalContent: {
|
||||
backgroundColor: colors.backgroundLight,
|
||||
borderRadius: radius.xl,
|
||||
padding: spacing.xl,
|
||||
alignItems: 'center',
|
||||
width: '90%',
|
||||
maxWidth: 340,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
...shadows.lg,
|
||||
},
|
||||
modalHeader: {
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
modalTitle: {
|
||||
...typography.title1,
|
||||
color: colors.text,
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
modalSubtitle: {
|
||||
...typography.body,
|
||||
color: colors.textSecondary,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
modalQuestion: {
|
||||
...typography.headline,
|
||||
color: colors.text,
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
matchTypeBadge: {
|
||||
backgroundColor: colors.primaryLight,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: radius.full,
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
matchTypeText: {
|
||||
...typography.caption1,
|
||||
color: colors.primary,
|
||||
fontWeight: '600',
|
||||
},
|
||||
dismissButton: {
|
||||
backgroundColor: colors.primary,
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: radius.lg,
|
||||
minWidth: 120,
|
||||
},
|
||||
dismissButtonText: {
|
||||
...typography.headline,
|
||||
color: colors.text,
|
||||
textAlign: 'center',
|
||||
},
|
||||
messageToast: {
|
||||
position: 'absolute',
|
||||
top: Platform.OS === 'ios' ? 50 : 20,
|
||||
|
||||
+249
-101
@@ -18,6 +18,7 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { GradientBackground, GlassCard, GlassButton, DecorativeSeparator } from "../../src/components/ui";
|
||||
import { SwipeableMatchItem } from "../../src/components/matches";
|
||||
import { useAmbientOrbAnimation } from "../../src/hooks";
|
||||
import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme";
|
||||
import { MatchesTutorial } from "../../src/components/tutorials";
|
||||
@@ -37,7 +38,23 @@ const HEADER_SCROLL_DISTANCE = 100;
|
||||
const AnimatedFlatList = Animated.createAnimatedComponent(FlatList);
|
||||
|
||||
export default function MatchesScreen() {
|
||||
const { matches, fetchMatches, markAllAsSeen, isLoading, hasMore, isLoadingMore, totalCount } = useMatchStore();
|
||||
const {
|
||||
matches,
|
||||
fetchMatches,
|
||||
markAllAsSeen,
|
||||
isLoading,
|
||||
hasMore,
|
||||
isLoadingMore,
|
||||
totalCount,
|
||||
// Archive state and methods
|
||||
archivedMatches,
|
||||
showArchived,
|
||||
toggleShowArchived,
|
||||
archiveMatch,
|
||||
unarchiveMatch,
|
||||
isLoadingArchived,
|
||||
fetchArchivedMatches,
|
||||
} = useMatchStore();
|
||||
const { user } = useAuthStore();
|
||||
const router = useRouter();
|
||||
const { width } = useWindowDimensions();
|
||||
@@ -150,84 +167,97 @@ export default function MatchesScreen() {
|
||||
|
||||
const isYesYes = item.match_type === "yes_yes";
|
||||
|
||||
const handleArchive = async () => {
|
||||
if (showArchived) {
|
||||
await unarchiveMatch(item.id);
|
||||
} else {
|
||||
await archiveMatch(item.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Animated.View entering={FadeInRight.delay(index * 50).duration(300)}>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push(`/chat/${item.id}`)}
|
||||
activeOpacity={0.8}
|
||||
<SwipeableMatchItem
|
||||
onArchive={handleArchive}
|
||||
isArchived={showArchived}
|
||||
>
|
||||
<View style={styles.matchCardPremium}>
|
||||
{/* Subtle gradient background */}
|
||||
<LinearGradient
|
||||
colors={['rgba(22, 33, 62, 0.6)', 'rgba(13, 13, 26, 0.8)']}
|
||||
style={StyleSheet.absoluteFill}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
{/* Top silk highlight */}
|
||||
<LinearGradient
|
||||
colors={[`${ACCENT_RGBA}0.06)`, 'transparent']}
|
||||
style={styles.cardSilkHighlight}
|
||||
start={{ x: 0.5, y: 0 }}
|
||||
end={{ x: 0.5, y: 1 }}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push(`/chat/${item.id}`)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<View style={styles.matchCardPremium}>
|
||||
{/* Subtle gradient background */}
|
||||
<LinearGradient
|
||||
colors={['rgba(22, 33, 62, 0.6)', 'rgba(13, 13, 26, 0.8)']}
|
||||
style={StyleSheet.absoluteFill}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
{/* Top silk highlight */}
|
||||
<LinearGradient
|
||||
colors={[`${ACCENT_RGBA}0.06)`, 'transparent']}
|
||||
style={styles.cardSilkHighlight}
|
||||
start={{ x: 0.5, y: 0 }}
|
||||
end={{ x: 0.5, y: 1 }}
|
||||
/>
|
||||
|
||||
<View style={styles.matchRow}>
|
||||
{/* Premium icon container */}
|
||||
<View style={[
|
||||
styles.iconContainerPremium,
|
||||
isYesYes && styles.iconContainerYesYes
|
||||
]}>
|
||||
<Ionicons
|
||||
name={isYesYes ? "heart" : "heart-half"}
|
||||
size={20}
|
||||
color={isYesYes ? ACCENT : ROSE}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.matchRow}>
|
||||
{/* Premium icon container */}
|
||||
<View style={[
|
||||
styles.iconContainerPremium,
|
||||
isYesYes && styles.iconContainerYesYes
|
||||
]}>
|
||||
<Ionicons
|
||||
name={isYesYes ? "heart" : "heart-half"}
|
||||
size={20}
|
||||
color={isYesYes ? ACCENT : ROSE}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.questionTextPremium} numberOfLines={2}>
|
||||
{userText}
|
||||
</Text>
|
||||
{item.question.partner_text && (
|
||||
<Text style={styles.partnerTextPremium} numberOfLines={1}>
|
||||
Partner: {partnerText}
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.questionTextPremium} numberOfLines={2}>
|
||||
{userText}
|
||||
</Text>
|
||||
)}
|
||||
<View style={styles.metaRow}>
|
||||
<View style={[
|
||||
styles.tagPremium,
|
||||
isYesYes && styles.tagPremiumHighlight
|
||||
]}>
|
||||
<Text style={[
|
||||
styles.tagTextPremium,
|
||||
isYesYes && styles.tagTextPremiumHighlight
|
||||
{item.question.partner_text && (
|
||||
<Text style={styles.partnerTextPremium} numberOfLines={1}>
|
||||
Partner: {partnerText}
|
||||
</Text>
|
||||
)}
|
||||
<View style={styles.metaRow}>
|
||||
<View style={[
|
||||
styles.tagPremium,
|
||||
isYesYes && styles.tagPremiumHighlight
|
||||
]}>
|
||||
{isYesYes ? "YES + YES" : "YES + MAYBE"}
|
||||
<Text style={[
|
||||
styles.tagTextPremium,
|
||||
isYesYes && styles.tagTextPremiumHighlight
|
||||
]}>
|
||||
{isYesYes ? "YES + YES" : "YES + MAYBE"}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.datePremium}>
|
||||
{new Date(item.created_at).toLocaleDateString()}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.datePremium}>
|
||||
{new Date(item.created_at).toLocaleDateString()}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.rightSection}>
|
||||
{item.unreadCount > 0 && (
|
||||
<View style={styles.unreadBadgePremium}>
|
||||
<Ionicons name="chatbubble" size={10} color={colors.text} />
|
||||
<View style={styles.rightSection}>
|
||||
{item.unreadCount > 0 && (
|
||||
<View style={styles.unreadBadgePremium}>
|
||||
<Ionicons name="chatbubble" size={10} color={colors.text} />
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.chevronContainerPremium}>
|
||||
<Ionicons name="chevron-forward" size={16} color={`${ACCENT_RGBA}0.6)`} />
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.chevronContainerPremium}>
|
||||
<Ionicons name="chevron-forward" size={16} color={`${ACCENT_RGBA}0.6)`} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Premium border */}
|
||||
<View style={styles.cardPremiumBorder} pointerEvents="none" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
{/* Premium border */}
|
||||
<View style={styles.cardPremiumBorder} pointerEvents="none" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</SwipeableMatchItem>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
@@ -296,7 +326,7 @@ export default function MatchesScreen() {
|
||||
</View>
|
||||
|
||||
<AnimatedFlatList
|
||||
data={matches}
|
||||
data={showArchived ? archivedMatches : matches}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item: any) => item.id}
|
||||
contentContainerStyle={[styles.list, isWideScreen && styles.listWide]}
|
||||
@@ -328,50 +358,122 @@ export default function MatchesScreen() {
|
||||
{/* Decorative separator */}
|
||||
<DecorativeSeparator variant="gold" />
|
||||
|
||||
{/* Filter toggle */}
|
||||
<View style={styles.filterContainer}>
|
||||
<TouchableOpacity
|
||||
style={[styles.filterTab, !showArchived && styles.filterTabActive]}
|
||||
onPress={() => showArchived && toggleShowArchived()}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons
|
||||
name="heart"
|
||||
size={14}
|
||||
color={!showArchived ? ACCENT : colors.textTertiary}
|
||||
/>
|
||||
<Text style={[styles.filterTabText, !showArchived && styles.filterTabTextActive]}>
|
||||
Active
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.filterTab, showArchived && styles.filterTabActive]}
|
||||
onPress={() => {
|
||||
if (!showArchived) {
|
||||
toggleShowArchived();
|
||||
}
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{isLoadingArchived ? (
|
||||
<ActivityIndicator size="small" color={ACCENT} />
|
||||
) : (
|
||||
<>
|
||||
<Ionicons
|
||||
name="archive-outline"
|
||||
size={14}
|
||||
color={showArchived ? ACCENT : colors.textTertiary}
|
||||
/>
|
||||
<Text style={[styles.filterTabText, showArchived && styles.filterTabTextActive]}>
|
||||
Archived
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Count badge */}
|
||||
<View style={styles.countBadgePremium}>
|
||||
<Ionicons name="heart" size={12} color={ACCENT} />
|
||||
<Text style={styles.countTextPremium}>{totalCount ?? matches.length} {(totalCount ?? matches.length) === 1 ? 'MATCH' : 'MATCHES'}</Text>
|
||||
<Ionicons name={showArchived ? "archive" : "heart"} size={12} color={ACCENT} />
|
||||
<Text style={styles.countTextPremium}>
|
||||
{showArchived
|
||||
? `${archivedMatches.length} ARCHIVED`
|
||||
: `${totalCount ?? matches.length} ${(totalCount ?? matches.length) === 1 ? 'MATCH' : 'MATCHES'}`
|
||||
}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
<Animated.View
|
||||
entering={FadeInUp.duration(600).springify()}
|
||||
style={styles.emptyContent}
|
||||
>
|
||||
{/* Description */}
|
||||
<Text style={styles.emptyDescription}>
|
||||
Answer questions together to discover what you both enjoy. Matches appear when you agree!
|
||||
</Text>
|
||||
|
||||
{/* Feature hints */}
|
||||
<View style={styles.emptyFeatures}>
|
||||
<View style={styles.emptyFeatureItem}>
|
||||
<Ionicons name="heart" size={16} color={ACCENT} />
|
||||
<Text style={styles.emptyFeatureText}>Swipe right for yes</Text>
|
||||
</View>
|
||||
<View style={styles.emptyFeatureItem}>
|
||||
<Ionicons name="sparkles" size={16} color={ACCENT} />
|
||||
<Text style={styles.emptyFeatureText}>Match when you both agree</Text>
|
||||
</View>
|
||||
<View style={styles.emptyFeatureItem}>
|
||||
<Ionicons name="chatbubbles-outline" size={16} color={ACCENT} />
|
||||
<Text style={styles.emptyFeatureText}>Chat about your matches</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Bottom teaser */}
|
||||
<Text style={styles.emptyTeaser}>Your first match is just a swipe away</Text>
|
||||
|
||||
<GlassButton
|
||||
onPress={() => router.push("/(app)/swipe")}
|
||||
style={{ marginTop: spacing.lg }}
|
||||
showArchived ? (
|
||||
<Animated.View
|
||||
entering={FadeInUp.duration(600).springify()}
|
||||
style={styles.emptyContent}
|
||||
>
|
||||
Start Swiping
|
||||
</GlassButton>
|
||||
</Animated.View>
|
||||
{/* Archive icon */}
|
||||
<View style={styles.emptyIconContainer}>
|
||||
<Ionicons name="archive-outline" size={48} color={ACCENT} />
|
||||
</View>
|
||||
|
||||
{/* Description */}
|
||||
<Text style={styles.emptyTitle}>No Archived Matches</Text>
|
||||
<Text style={styles.emptyDescription}>
|
||||
Matches you archive will appear here. Swipe left on any match to archive it.
|
||||
</Text>
|
||||
|
||||
<GlassButton
|
||||
onPress={() => toggleShowArchived()}
|
||||
style={{ marginTop: spacing.lg }}
|
||||
>
|
||||
View Active Matches
|
||||
</GlassButton>
|
||||
</Animated.View>
|
||||
) : (
|
||||
<Animated.View
|
||||
entering={FadeInUp.duration(600).springify()}
|
||||
style={styles.emptyContent}
|
||||
>
|
||||
{/* Description */}
|
||||
<Text style={styles.emptyDescription}>
|
||||
Answer questions together to discover what you both enjoy. Matches appear when you agree!
|
||||
</Text>
|
||||
|
||||
{/* Feature hints */}
|
||||
<View style={styles.emptyFeatures}>
|
||||
<View style={styles.emptyFeatureItem}>
|
||||
<Ionicons name="heart" size={16} color={ACCENT} />
|
||||
<Text style={styles.emptyFeatureText}>Swipe right for yes</Text>
|
||||
</View>
|
||||
<View style={styles.emptyFeatureItem}>
|
||||
<Ionicons name="sparkles" size={16} color={ACCENT} />
|
||||
<Text style={styles.emptyFeatureText}>Match when you both agree</Text>
|
||||
</View>
|
||||
<View style={styles.emptyFeatureItem}>
|
||||
<Ionicons name="chatbubbles-outline" size={16} color={ACCENT} />
|
||||
<Text style={styles.emptyFeatureText}>Chat about your matches</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Bottom teaser */}
|
||||
<Text style={styles.emptyTeaser}>Your first match is just a swipe away</Text>
|
||||
|
||||
<GlassButton
|
||||
onPress={() => router.push("/(app)/swipe")}
|
||||
style={{ marginTop: spacing.lg }}
|
||||
>
|
||||
Start Swiping
|
||||
</GlassButton>
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
@@ -478,6 +580,36 @@ const styles = StyleSheet.create({
|
||||
color: colors.text,
|
||||
textAlign: 'center',
|
||||
},
|
||||
// Filter toggle
|
||||
filterContainer: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
borderRadius: radius.lg,
|
||||
padding: 4,
|
||||
marginBottom: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.08)',
|
||||
},
|
||||
filterTab: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.md,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
filterTabActive: {
|
||||
backgroundColor: `${ACCENT_RGBA}0.15)`,
|
||||
},
|
||||
filterTabText: {
|
||||
...typography.caption1,
|
||||
color: colors.textTertiary,
|
||||
fontWeight: '500',
|
||||
},
|
||||
filterTabTextActive: {
|
||||
color: ACCENT,
|
||||
fontWeight: '600',
|
||||
},
|
||||
countBadgePremium: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
@@ -628,6 +760,22 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xl,
|
||||
},
|
||||
emptyIconContainer: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
backgroundColor: `${ACCENT_RGBA}0.1)`,
|
||||
borderWidth: 1,
|
||||
borderColor: `${ACCENT_RGBA}0.2)`,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
emptyTitle: {
|
||||
...typography.title3,
|
||||
color: colors.text,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
emptyDescription: {
|
||||
...typography.body,
|
||||
color: colors.textSecondary,
|
||||
|
||||
+333
-14
@@ -24,9 +24,18 @@ import { SwipeCardPremium as SwipeCard } from "../../src/components/swipe"; // P
|
||||
import { SwipeTutorial } from "../../src/components/tutorials";
|
||||
import { QuestionFeedbackModal } from "../../src/components/feedback";
|
||||
import { GradientBackground, GlassCard, GlassButton, DecorativeSeparator } from "../../src/components/ui";
|
||||
import { Paywall } from "../../src/components/paywall";
|
||||
import { Events } from "../../src/lib/analytics";
|
||||
import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme";
|
||||
|
||||
interface DailyLimitInfo {
|
||||
responses_today: number;
|
||||
limit_value: number;
|
||||
remaining: number;
|
||||
reset_at: string;
|
||||
is_blocked: boolean;
|
||||
}
|
||||
|
||||
export default function SwipeScreen() {
|
||||
const params = useLocalSearchParams();
|
||||
// Normalize packId - useLocalSearchParams can return string | string[]
|
||||
@@ -38,6 +47,8 @@ export default function SwipeScreen() {
|
||||
const [feedbackQuestion, setFeedbackQuestion] = useState<{id: string, text: string} | null>(null);
|
||||
const [isBlocked, setIsBlocked] = useState(false);
|
||||
const [gapInfo, setGapInfo] = useState<{ unanswered: number; threshold: number } | null>(null);
|
||||
const [dailyLimitInfo, setDailyLimitInfo] = useState<DailyLimitInfo | null>(null);
|
||||
const [showPaywall, setShowPaywall] = useState(false);
|
||||
const { enabledPackIds, ensureEnabledPacksLoaded } = usePacksStore();
|
||||
const { partner, couple } = useAuthStore();
|
||||
const hasTrackedExhausted = useRef(false);
|
||||
@@ -49,6 +60,40 @@ export default function SwipeScreen() {
|
||||
// Progress bar shimmer animation
|
||||
const shimmerPosition = useSharedValue(-1);
|
||||
|
||||
// Countdown for daily limit reset
|
||||
const [countdown, setCountdown] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!dailyLimitInfo?.is_blocked || !dailyLimitInfo.reset_at) {
|
||||
setCountdown("");
|
||||
return;
|
||||
}
|
||||
|
||||
const updateCountdown = () => {
|
||||
const now = new Date();
|
||||
const reset = new Date(dailyLimitInfo.reset_at);
|
||||
const diff = reset.getTime() - now.getTime();
|
||||
|
||||
if (diff <= 0) {
|
||||
setCountdown("00:00:00");
|
||||
// Refresh questions when timer expires
|
||||
fetchQuestions();
|
||||
return;
|
||||
}
|
||||
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
|
||||
|
||||
setCountdown(`${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`);
|
||||
};
|
||||
|
||||
updateCountdown();
|
||||
const interval = setInterval(updateCountdown, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [dailyLimitInfo?.is_blocked, dailyLimitInfo?.reset_at]);
|
||||
|
||||
useEffect(() => {
|
||||
// Progress bar shimmer sweep - 2.5 second cycle
|
||||
shimmerPosition.value = withRepeat(
|
||||
@@ -139,16 +184,48 @@ export default function SwipeScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate effective total questions (accounting for gap limit)
|
||||
const effectiveTotal = (() => {
|
||||
if (!gapInfo || gapInfo.threshold === 0) {
|
||||
// No gap limit active, show all questions
|
||||
return questions.length;
|
||||
// Check if user has reached daily response limit
|
||||
const checkDailyLimit = async (): Promise<boolean> => {
|
||||
try {
|
||||
const { data, error } = await supabase.rpc('get_daily_response_limit_status');
|
||||
|
||||
if (error) {
|
||||
console.error('Error checking daily limit:', error);
|
||||
setDailyLimitInfo(null);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (data && data.length > 0) {
|
||||
const result = data[0];
|
||||
setDailyLimitInfo(result);
|
||||
return result.is_blocked;
|
||||
}
|
||||
|
||||
setDailyLimitInfo(null);
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Failed to check daily limit:', error);
|
||||
setDailyLimitInfo(null);
|
||||
return false;
|
||||
}
|
||||
// remaining = how many more questions until blocked
|
||||
const remaining = gapInfo.threshold - gapInfo.unanswered;
|
||||
// effective total = current position + remaining (capped by actual questions)
|
||||
return Math.min(questions.length, currentIndex + Math.max(0, remaining));
|
||||
};
|
||||
|
||||
// Calculate effective total questions (accounting for gap limit AND daily limit)
|
||||
const effectiveTotal = (() => {
|
||||
let total = questions.length;
|
||||
|
||||
// Apply gap limit if active
|
||||
if (gapInfo && gapInfo.threshold > 0) {
|
||||
const remaining = gapInfo.threshold - gapInfo.unanswered;
|
||||
total = Math.min(total, currentIndex + Math.max(0, remaining));
|
||||
}
|
||||
|
||||
// Apply daily limit if active
|
||||
if (dailyLimitInfo && dailyLimitInfo.limit_value > 0 && !dailyLimitInfo.is_blocked) {
|
||||
total = Math.min(total, currentIndex + dailyLimitInfo.remaining);
|
||||
}
|
||||
|
||||
return total;
|
||||
})();
|
||||
|
||||
// Track when all questions are exhausted
|
||||
@@ -212,14 +289,14 @@ export default function SwipeScreen() {
|
||||
// Filter out recently skipped questions
|
||||
const filtered = (data || []).filter((q: any) => !skippedIds.has(q.id));
|
||||
|
||||
// Always check answer gap when user has partner and not viewing specific pack
|
||||
// This gives us the gap info for accurate counter display
|
||||
// Always check answer gap and daily limit when user has partner and not viewing specific pack
|
||||
if (partner && !packId) {
|
||||
await checkAnswerGap();
|
||||
await Promise.all([checkAnswerGap(), checkDailyLimit()]);
|
||||
} else {
|
||||
// Reset blocked state if no partner or viewing specific pack
|
||||
setIsBlocked(false);
|
||||
setGapInfo(null);
|
||||
setDailyLimitInfo(null);
|
||||
}
|
||||
|
||||
// Server provides ordering (daily seeded random with priority)
|
||||
@@ -264,8 +341,19 @@ export default function SwipeScreen() {
|
||||
console.error("Submit response error:", error);
|
||||
} else {
|
||||
Events.questionAnswered(answer, question.pack_id);
|
||||
// Check if we've hit the answer gap threshold
|
||||
await checkAnswerGap();
|
||||
|
||||
// Increment local daily count if we have limit info
|
||||
if (dailyLimitInfo && dailyLimitInfo.limit_value > 0) {
|
||||
setDailyLimitInfo(prev => prev ? {
|
||||
...prev,
|
||||
responses_today: prev.responses_today + 1,
|
||||
remaining: Math.max(0, prev.remaining - 1),
|
||||
is_blocked: prev.responses_today + 1 >= prev.limit_value
|
||||
} : null);
|
||||
}
|
||||
|
||||
// Check both gap and daily limit
|
||||
await Promise.all([checkAnswerGap(), checkDailyLimit()]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to submit response", error);
|
||||
@@ -350,6 +438,117 @@ export default function SwipeScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
// Show "daily limit reached" screen when user has exhausted their daily quota
|
||||
if (dailyLimitInfo?.is_blocked) {
|
||||
const LIMIT_ACCENT = colors.premium.gold;
|
||||
return (
|
||||
<GradientBackground>
|
||||
{/* Ambient Orbs - Gold/warm glow */}
|
||||
<Animated.View style={[styles.ambientOrb, styles.dailyLimitOrbTop, orbStyle1]} pointerEvents="none">
|
||||
<LinearGradient
|
||||
colors={[colors.premium.goldGlow, 'transparent']}
|
||||
style={styles.orbGradient}
|
||||
start={{ x: 0.5, y: 0.5 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
</Animated.View>
|
||||
<Animated.View style={[styles.ambientOrb, styles.dailyLimitOrbBottom, orbStyle2]} pointerEvents="none">
|
||||
<LinearGradient
|
||||
colors={['rgba(212, 175, 55, 0.15)', 'transparent']}
|
||||
style={styles.orbGradient}
|
||||
start={{ x: 0.5, y: 0.5 }}
|
||||
end={{ x: 0, y: 0 }}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
<View style={styles.centerContainer}>
|
||||
<Animated.View
|
||||
entering={FadeInUp.duration(600).springify()}
|
||||
style={styles.waitingContent}
|
||||
>
|
||||
{/* Icon with glow effect */}
|
||||
<View style={styles.dailyLimitIconWrapper}>
|
||||
<View style={styles.dailyLimitIconGlow} />
|
||||
<View style={styles.dailyLimitIconContainer}>
|
||||
<Ionicons name="hourglass-outline" size={36} color={LIMIT_ACCENT} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Title section */}
|
||||
<Text style={styles.dailyLimitLabel}>DAILY LIMIT REACHED</Text>
|
||||
<Text style={styles.dailyLimitTitle}>Take a Breather</Text>
|
||||
|
||||
<DecorativeSeparator variant="gold" />
|
||||
|
||||
{/* Countdown timer - prominent display */}
|
||||
<Animated.View
|
||||
entering={FadeIn.delay(300).duration(400)}
|
||||
style={styles.countdownContainer}
|
||||
>
|
||||
<Text style={styles.countdownLabel}>REFRESHES IN</Text>
|
||||
<View style={styles.countdownBadge}>
|
||||
<Ionicons name="timer-outline" size={20} color={LIMIT_ACCENT} />
|
||||
<Text style={styles.countdownText}>{countdown}</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
{/* Description */}
|
||||
<Text style={styles.dailyLimitDescription}>
|
||||
You've answered {dailyLimitInfo.limit_value} questions today!{'\n'}
|
||||
Let the anticipation build while you wait.
|
||||
</Text>
|
||||
|
||||
{/* Feature hints - vertical list */}
|
||||
<View style={styles.waitingFeatures}>
|
||||
<View style={styles.waitingFeatureItem}>
|
||||
<Ionicons name="chatbubbles-outline" size={16} color={colors.premium.gold} />
|
||||
<Text style={styles.waitingFeatureText}>Chat about your matches</Text>
|
||||
</View>
|
||||
<View style={styles.waitingFeatureItem}>
|
||||
<Ionicons name="sparkles" size={16} color={colors.premium.gold} />
|
||||
<Text style={styles.waitingFeatureText}>Fresh questions tomorrow</Text>
|
||||
</View>
|
||||
<View style={styles.waitingFeatureItem}>
|
||||
<Ionicons name="infinite-outline" size={16} color={colors.premium.gold} />
|
||||
<Text style={styles.waitingFeatureText}>Go unlimited with Premium</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Premium upsell button */}
|
||||
<Text style={styles.premiumUpsellText}>Want to keep exploring?</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => setShowPaywall(true)}
|
||||
style={styles.premiumButton}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={[colors.premium.gold, colors.premium.goldDark]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.premiumButtonGradient}
|
||||
/>
|
||||
<View style={styles.premiumButtonContent}>
|
||||
<Ionicons name="infinite" size={18} color="#000" />
|
||||
<Text style={styles.premiumButtonText}>Unlock Unlimited</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
</View>
|
||||
|
||||
{/* Paywall Modal */}
|
||||
<Paywall
|
||||
visible={showPaywall}
|
||||
onClose={() => setShowPaywall(false)}
|
||||
onSuccess={() => {
|
||||
setShowPaywall(false);
|
||||
// Refresh to clear limit status
|
||||
fetchQuestions();
|
||||
}}
|
||||
/>
|
||||
</GradientBackground>
|
||||
);
|
||||
}
|
||||
|
||||
// Show "waiting for partner" message when user is too far ahead
|
||||
if (isBlocked && gapInfo) {
|
||||
const WAITING_ACCENT = colors.premium.rose;
|
||||
@@ -656,6 +855,17 @@ export default function SwipeScreen() {
|
||||
questionId={feedbackQuestion?.id || ''}
|
||||
questionText={feedbackQuestion?.text || ''}
|
||||
/>
|
||||
|
||||
{/* Paywall Modal */}
|
||||
<Paywall
|
||||
visible={showPaywall}
|
||||
onClose={() => setShowPaywall(false)}
|
||||
onSuccess={() => {
|
||||
setShowPaywall(false);
|
||||
// Refresh to clear limit status
|
||||
fetchQuestions();
|
||||
}}
|
||||
/>
|
||||
</GradientBackground>
|
||||
);
|
||||
}
|
||||
@@ -889,4 +1099,113 @@ const styles = StyleSheet.create({
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
// Daily limit screen styles
|
||||
dailyLimitOrbTop: {
|
||||
top: 80,
|
||||
right: -60,
|
||||
},
|
||||
dailyLimitOrbBottom: {
|
||||
bottom: 200,
|
||||
left: -80,
|
||||
},
|
||||
dailyLimitIconWrapper: {
|
||||
position: 'relative',
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
dailyLimitIconGlow: {
|
||||
position: 'absolute',
|
||||
width: 120,
|
||||
height: 120,
|
||||
borderRadius: 60,
|
||||
backgroundColor: 'rgba(212, 175, 55, 0.15)',
|
||||
top: -24,
|
||||
left: -24,
|
||||
},
|
||||
dailyLimitIconContainer: {
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 36,
|
||||
backgroundColor: 'rgba(212, 175, 55, 0.1)',
|
||||
borderWidth: 1.5,
|
||||
borderColor: 'rgba(212, 175, 55, 0.3)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
dailyLimitLabel: {
|
||||
...typography.caption2,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 2,
|
||||
color: colors.premium.gold,
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
dailyLimitTitle: {
|
||||
...typography.largeTitle,
|
||||
color: colors.text,
|
||||
textAlign: 'center',
|
||||
},
|
||||
countdownContainer: {
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
countdownLabel: {
|
||||
...typography.caption2,
|
||||
letterSpacing: 1.5,
|
||||
color: colors.textTertiary,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
countdownBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(212, 175, 55, 0.1)',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: radius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(212, 175, 55, 0.25)',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
countdownText: {
|
||||
...typography.title2,
|
||||
fontWeight: '700',
|
||||
color: colors.premium.gold,
|
||||
fontVariant: ['tabular-nums'],
|
||||
letterSpacing: 2,
|
||||
},
|
||||
dailyLimitDescription: {
|
||||
...typography.body,
|
||||
color: colors.textSecondary,
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.lg,
|
||||
lineHeight: 24,
|
||||
paddingHorizontal: spacing.md,
|
||||
},
|
||||
premiumUpsellText: {
|
||||
...typography.footnote,
|
||||
fontStyle: 'italic',
|
||||
color: colors.textTertiary,
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
premiumButton: {
|
||||
position: 'relative',
|
||||
borderRadius: radius.lg,
|
||||
overflow: 'hidden',
|
||||
...shadows.md,
|
||||
},
|
||||
premiumButtonGradient: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
premiumButtonContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.xl,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
premiumButtonText: {
|
||||
...typography.body,
|
||||
fontWeight: '600',
|
||||
color: '#000',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
import React from 'react';
|
||||
import { render, fireEvent, waitFor } from '@testing-library/react-native';
|
||||
import { Text, View, Platform } from 'react-native';
|
||||
import { GlassButton } from '@/components/ui/GlassButton';
|
||||
|
||||
// Store the mock for expo-haptics so we can check calls
|
||||
const mockImpactAsync = jest.fn();
|
||||
|
||||
// Mock expo-haptics - must happen before import
|
||||
jest.mock('expo-haptics', () => ({
|
||||
impactAsync: mockImpactAsync,
|
||||
ImpactFeedbackStyle: {
|
||||
Light: 'light',
|
||||
},
|
||||
}));
|
||||
|
||||
describe('GlassButton', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders children text correctly', () => {
|
||||
const { getByText } = render(
|
||||
<GlassButton>Click Me</GlassButton>
|
||||
);
|
||||
|
||||
expect(getByText('Click Me')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders with primary variant by default', () => {
|
||||
const { getByText } = render(
|
||||
<GlassButton>Primary Button</GlassButton>
|
||||
);
|
||||
|
||||
const text = getByText('Primary Button');
|
||||
expect(text).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders secondary variant', () => {
|
||||
const { getByText } = render(
|
||||
<GlassButton variant="secondary">Secondary Button</GlassButton>
|
||||
);
|
||||
|
||||
expect(getByText('Secondary Button')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders ghost variant', () => {
|
||||
const { getByText } = render(
|
||||
<GlassButton variant="ghost">Ghost Button</GlassButton>
|
||||
);
|
||||
|
||||
expect(getByText('Ghost Button')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders danger variant', () => {
|
||||
const { getByText } = render(
|
||||
<GlassButton variant="danger">Danger Button</GlassButton>
|
||||
);
|
||||
|
||||
expect(getByText('Danger Button')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders with icon when provided', () => {
|
||||
const TestIcon = () => <Text testID="test-icon">Icon</Text>;
|
||||
const { getByTestId, getByText } = render(
|
||||
<GlassButton icon={<TestIcon />}>With Icon</GlassButton>
|
||||
);
|
||||
|
||||
expect(getByTestId('test-icon')).toBeTruthy();
|
||||
expect(getByText('With Icon')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders loading indicator when loading is true', () => {
|
||||
const { queryByText, UNSAFE_getByType } = render(
|
||||
<GlassButton loading>Loading Button</GlassButton>
|
||||
);
|
||||
|
||||
// When loading, text should not be visible
|
||||
expect(queryByText('Loading Button')).toBeNull();
|
||||
|
||||
// Activity indicator should be present
|
||||
const ActivityIndicator = require('react-native').ActivityIndicator;
|
||||
expect(UNSAFE_getByType(ActivityIndicator)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders with different sizes', () => {
|
||||
const { rerender, getByText } = render(
|
||||
<GlassButton size="sm">Small</GlassButton>
|
||||
);
|
||||
expect(getByText('Small')).toBeTruthy();
|
||||
|
||||
rerender(<GlassButton size="md">Medium</GlassButton>);
|
||||
expect(getByText('Medium')).toBeTruthy();
|
||||
|
||||
rerender(<GlassButton size="lg">Large</GlassButton>);
|
||||
expect(getByText('Large')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('press handling', () => {
|
||||
it('calls onPress when pressed', async () => {
|
||||
const onPress = jest.fn();
|
||||
const { getByText } = render(
|
||||
<GlassButton onPress={onPress} haptic={false}>Press Me</GlassButton>
|
||||
);
|
||||
|
||||
fireEvent.press(getByText('Press Me'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onPress).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call onPress when disabled', () => {
|
||||
const onPress = jest.fn();
|
||||
const { getByText } = render(
|
||||
<GlassButton onPress={onPress} disabled>
|
||||
Disabled Button
|
||||
</GlassButton>
|
||||
);
|
||||
|
||||
fireEvent.press(getByText('Disabled Button'));
|
||||
|
||||
expect(onPress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call onPress when loading', () => {
|
||||
const onPress = jest.fn();
|
||||
const { UNSAFE_getByType } = render(
|
||||
<GlassButton onPress={onPress} loading>
|
||||
Loading Button
|
||||
</GlassButton>
|
||||
);
|
||||
|
||||
const ActivityIndicator = require('react-native').ActivityIndicator;
|
||||
const indicator = UNSAFE_getByType(ActivityIndicator);
|
||||
|
||||
// Try to press the parent pressable
|
||||
// Since loading hides the text, we can't press by text
|
||||
// The component should still be non-pressable
|
||||
expect(onPress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Note: Haptic tests use haptic={false} because the component uses dynamic import
|
||||
// which doesn't work properly in Jest without --experimental-vm-modules
|
||||
it('handles button with haptic prop', () => {
|
||||
// Verify the component renders with haptic prop
|
||||
// Actual haptic functionality cannot be tested due to dynamic import limitations
|
||||
const { getByText } = render(
|
||||
<GlassButton haptic>Haptic Button</GlassButton>
|
||||
);
|
||||
|
||||
expect(getByText('Haptic Button')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('calls onPress when haptic is false', async () => {
|
||||
const onPress = jest.fn();
|
||||
const { getByText } = render(
|
||||
<GlassButton onPress={onPress} haptic={false}>
|
||||
No Haptic
|
||||
</GlassButton>
|
||||
);
|
||||
|
||||
fireEvent.press(getByText('No Haptic'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onPress).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('styling', () => {
|
||||
it('applies fullWidth style when fullWidth is true', () => {
|
||||
const { getByText } = render(
|
||||
<GlassButton fullWidth>Full Width Button</GlassButton>
|
||||
);
|
||||
|
||||
const button = getByText('Full Width Button');
|
||||
expect(button).toBeTruthy();
|
||||
// The fullWidth style is applied to parent, which sets width: '100%'
|
||||
});
|
||||
|
||||
it('applies custom style prop', () => {
|
||||
const customStyle = { marginTop: 20 };
|
||||
const { getByText } = render(
|
||||
<GlassButton style={customStyle}>Styled Button</GlassButton>
|
||||
);
|
||||
|
||||
expect(getByText('Styled Button')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('applies custom textStyle prop', () => {
|
||||
const customTextStyle = { fontWeight: 'bold' as const };
|
||||
const { getByText } = render(
|
||||
<GlassButton textStyle={customTextStyle}>Styled Text</GlassButton>
|
||||
);
|
||||
|
||||
expect(getByText('Styled Text')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('applies disabled opacity when disabled', () => {
|
||||
const { getByText } = render(
|
||||
<GlassButton disabled>Disabled Button</GlassButton>
|
||||
);
|
||||
|
||||
// The disabled style reduces opacity to 0.5
|
||||
expect(getByText('Disabled Button')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('press in/out animations', () => {
|
||||
it('handles press in event', () => {
|
||||
const { getByText } = render(
|
||||
<GlassButton>Animated Button</GlassButton>
|
||||
);
|
||||
|
||||
const button = getByText('Animated Button');
|
||||
|
||||
// Trigger press in - this should work without errors
|
||||
fireEvent(button, 'pressIn');
|
||||
expect(button).toBeTruthy();
|
||||
});
|
||||
|
||||
it('handles press out event', () => {
|
||||
const { getByText } = render(
|
||||
<GlassButton>Animated Button</GlassButton>
|
||||
);
|
||||
|
||||
const button = getByText('Animated Button');
|
||||
|
||||
// Trigger press in and out
|
||||
fireEvent(button, 'pressIn');
|
||||
fireEvent(button, 'pressOut');
|
||||
expect(button).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('default props', () => {
|
||||
it('has default variant of primary', () => {
|
||||
const { getByText } = render(<GlassButton>Default</GlassButton>);
|
||||
expect(getByText('Default')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('has default size of md', () => {
|
||||
const { getByText } = render(<GlassButton>Default Size</GlassButton>);
|
||||
expect(getByText('Default Size')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('has haptic enabled by default', () => {
|
||||
// The component has haptic={true} by default
|
||||
// We can't test the actual haptic call due to dynamic import limitations
|
||||
// but we verify the prop default works
|
||||
const { getByText } = render(<GlassButton>Default Haptic</GlassButton>);
|
||||
expect(getByText('Default Haptic')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('has disabled false by default', async () => {
|
||||
const onPress = jest.fn();
|
||||
const { getByText } = render(
|
||||
<GlassButton onPress={onPress} haptic={false}>Not Disabled</GlassButton>
|
||||
);
|
||||
|
||||
fireEvent.press(getByText('Not Disabled'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onPress).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('has loading false by default', () => {
|
||||
const { getByText } = render(
|
||||
<GlassButton>Not Loading</GlassButton>
|
||||
);
|
||||
|
||||
expect(getByText('Not Loading')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('has fullWidth false by default', () => {
|
||||
const { getByText } = render(<GlassButton>Not Full Width</GlassButton>);
|
||||
expect(getByText('Not Full Width')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,299 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react-native';
|
||||
import { Text, Platform } from 'react-native';
|
||||
import { GlassCard } from '@/components/ui/GlassCard';
|
||||
|
||||
// Mock expo-blur
|
||||
jest.mock('expo-blur', () => {
|
||||
const React = require('react');
|
||||
const { View } = require('react-native');
|
||||
|
||||
return {
|
||||
BlurView: ({ children, intensity, tint, style, ...props }: any) =>
|
||||
React.createElement(
|
||||
View,
|
||||
{ ...props, style, testID: 'blur-view', 'data-intensity': intensity, 'data-tint': tint },
|
||||
children
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
// Store original Platform.OS
|
||||
const originalPlatformOS = Platform.OS;
|
||||
|
||||
describe('GlassCard', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Reset to iOS for consistent testing
|
||||
Object.defineProperty(Platform, 'OS', {
|
||||
get: () => 'ios',
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Object.defineProperty(Platform, 'OS', {
|
||||
get: () => originalPlatformOS,
|
||||
});
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders children correctly', () => {
|
||||
const { getByText } = render(
|
||||
<GlassCard>
|
||||
<Text>Card Content</Text>
|
||||
</GlassCard>
|
||||
);
|
||||
|
||||
expect(getByText('Card Content')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders multiple children', () => {
|
||||
const { getByText } = render(
|
||||
<GlassCard>
|
||||
<Text>First Child</Text>
|
||||
<Text>Second Child</Text>
|
||||
</GlassCard>
|
||||
);
|
||||
|
||||
expect(getByText('First Child')).toBeTruthy();
|
||||
expect(getByText('Second Child')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders nested components', () => {
|
||||
const { getByText, getByTestId } = render(
|
||||
<GlassCard>
|
||||
<Text testID="nested-text">Nested Content</Text>
|
||||
</GlassCard>
|
||||
);
|
||||
|
||||
expect(getByTestId('nested-text')).toBeTruthy();
|
||||
expect(getByText('Nested Content')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('platform behavior', () => {
|
||||
it('uses BlurView on iOS', () => {
|
||||
Object.defineProperty(Platform, 'OS', {
|
||||
get: () => 'ios',
|
||||
});
|
||||
|
||||
const { getByTestId } = render(
|
||||
<GlassCard>
|
||||
<Text>iOS Card</Text>
|
||||
</GlassCard>
|
||||
);
|
||||
|
||||
expect(getByTestId('blur-view')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Note: Testing Android platform requires component re-evaluation
|
||||
// which is complex with module-level Platform checks
|
||||
it('renders correctly', () => {
|
||||
const { getByText } = render(
|
||||
<GlassCard>
|
||||
<Text>Card Content</Text>
|
||||
</GlassCard>
|
||||
);
|
||||
|
||||
expect(getByText('Card Content')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('intensity prop', () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(Platform, 'OS', {
|
||||
get: () => 'ios',
|
||||
});
|
||||
});
|
||||
|
||||
it('applies light intensity', () => {
|
||||
const { getByTestId } = render(
|
||||
<GlassCard intensity="light">
|
||||
<Text>Light Card</Text>
|
||||
</GlassCard>
|
||||
);
|
||||
|
||||
const blurView = getByTestId('blur-view');
|
||||
expect(blurView).toBeTruthy();
|
||||
});
|
||||
|
||||
it('applies medium intensity by default', () => {
|
||||
const { getByTestId } = render(
|
||||
<GlassCard>
|
||||
<Text>Medium Card</Text>
|
||||
</GlassCard>
|
||||
);
|
||||
|
||||
const blurView = getByTestId('blur-view');
|
||||
expect(blurView).toBeTruthy();
|
||||
});
|
||||
|
||||
it('applies heavy intensity', () => {
|
||||
const { getByTestId } = render(
|
||||
<GlassCard intensity="heavy">
|
||||
<Text>Heavy Card</Text>
|
||||
</GlassCard>
|
||||
);
|
||||
|
||||
const blurView = getByTestId('blur-view');
|
||||
expect(blurView).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('variant prop', () => {
|
||||
it('renders default variant', () => {
|
||||
const { getByText } = render(
|
||||
<GlassCard variant="default">
|
||||
<Text>Default Card</Text>
|
||||
</GlassCard>
|
||||
);
|
||||
|
||||
expect(getByText('Default Card')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders elevated variant', () => {
|
||||
const { getByText } = render(
|
||||
<GlassCard variant="elevated">
|
||||
<Text>Elevated Card</Text>
|
||||
</GlassCard>
|
||||
);
|
||||
|
||||
expect(getByText('Elevated Card')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders subtle variant', () => {
|
||||
const { getByText } = render(
|
||||
<GlassCard variant="subtle">
|
||||
<Text>Subtle Card</Text>
|
||||
</GlassCard>
|
||||
);
|
||||
|
||||
expect(getByText('Subtle Card')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('noPadding prop', () => {
|
||||
it('has padding by default', () => {
|
||||
const { getByText } = render(
|
||||
<GlassCard>
|
||||
<Text>Padded Card</Text>
|
||||
</GlassCard>
|
||||
);
|
||||
|
||||
expect(getByText('Padded Card')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('removes padding when noPadding is true', () => {
|
||||
const { getByText } = render(
|
||||
<GlassCard noPadding>
|
||||
<Text>No Padding Card</Text>
|
||||
</GlassCard>
|
||||
);
|
||||
|
||||
expect(getByText('No Padding Card')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('style prop', () => {
|
||||
it('applies custom style', () => {
|
||||
const customStyle = { marginTop: 20, backgroundColor: 'red' };
|
||||
const { getByText } = render(
|
||||
<GlassCard style={customStyle}>
|
||||
<Text>Styled Card</Text>
|
||||
</GlassCard>
|
||||
);
|
||||
|
||||
expect(getByText('Styled Card')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('merges custom style with default styles', () => {
|
||||
const { getByText } = render(
|
||||
<GlassCard style={{ marginBottom: 10 }}>
|
||||
<Text>Merged Styles Card</Text>
|
||||
</GlassCard>
|
||||
);
|
||||
|
||||
expect(getByText('Merged Styles Card')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('default props', () => {
|
||||
it('has default intensity of medium', () => {
|
||||
const { getByText } = render(
|
||||
<GlassCard>
|
||||
<Text>Default Intensity</Text>
|
||||
</GlassCard>
|
||||
);
|
||||
|
||||
expect(getByText('Default Intensity')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('has default variant of default', () => {
|
||||
const { getByText } = render(
|
||||
<GlassCard>
|
||||
<Text>Default Variant</Text>
|
||||
</GlassCard>
|
||||
);
|
||||
|
||||
expect(getByText('Default Variant')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('has noPadding false by default', () => {
|
||||
const { getByText } = render(
|
||||
<GlassCard>
|
||||
<Text>Has Padding</Text>
|
||||
</GlassCard>
|
||||
);
|
||||
|
||||
expect(getByText('Has Padding')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('gradient highlight', () => {
|
||||
it('renders highlight gradient', () => {
|
||||
Object.defineProperty(Platform, 'OS', {
|
||||
get: () => 'ios',
|
||||
});
|
||||
const { getByText } = render(
|
||||
<GlassCard>
|
||||
<Text>Card with Highlight</Text>
|
||||
</GlassCard>
|
||||
);
|
||||
|
||||
// LinearGradient is mocked to render as View, so we just verify the card renders
|
||||
expect(getByText('Card with Highlight')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('renders children accessible to screen readers', () => {
|
||||
const { getByText } = render(
|
||||
<GlassCard>
|
||||
<Text accessibilityLabel="Accessible content">Content</Text>
|
||||
</GlassCard>
|
||||
);
|
||||
|
||||
const text = getByText('Content');
|
||||
expect(text.props.accessibilityLabel).toBe('Accessible content');
|
||||
});
|
||||
});
|
||||
|
||||
describe('combination of props', () => {
|
||||
it('handles all props together', () => {
|
||||
const customStyle = { margin: 10 };
|
||||
|
||||
const { getByText } = render(
|
||||
<GlassCard
|
||||
intensity="heavy"
|
||||
variant="elevated"
|
||||
noPadding
|
||||
style={customStyle}
|
||||
>
|
||||
<Text>Full Props Card</Text>
|
||||
</GlassCard>
|
||||
);
|
||||
|
||||
expect(getByText('Full Props Card')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,380 @@
|
||||
import React from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react-native';
|
||||
import { Text, Platform } from 'react-native';
|
||||
import { GlassInput } from '@/components/ui/GlassInput';
|
||||
|
||||
// Mock expo-blur
|
||||
jest.mock('expo-blur', () => {
|
||||
const React = require('react');
|
||||
const { View } = require('react-native');
|
||||
|
||||
return {
|
||||
BlurView: ({ children, intensity, tint, style, ...props }: any) =>
|
||||
React.createElement(
|
||||
View,
|
||||
{ ...props, style, testID: 'blur-view' },
|
||||
children
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
// Store original Platform.OS
|
||||
const originalPlatformOS = Platform.OS;
|
||||
|
||||
describe('GlassInput', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Reset to iOS for consistent testing
|
||||
Object.defineProperty(Platform, 'OS', {
|
||||
get: () => 'ios',
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// Restore original
|
||||
Object.defineProperty(Platform, 'OS', {
|
||||
get: () => originalPlatformOS,
|
||||
});
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders text input', () => {
|
||||
const { getByPlaceholderText } = render(
|
||||
<GlassInput placeholder="Enter text" />
|
||||
);
|
||||
|
||||
expect(getByPlaceholderText('Enter text')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders with label', () => {
|
||||
const { getByText, getByPlaceholderText } = render(
|
||||
<GlassInput label="Username" placeholder="Enter username" />
|
||||
);
|
||||
|
||||
expect(getByText('Username')).toBeTruthy();
|
||||
expect(getByPlaceholderText('Enter username')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders without label', () => {
|
||||
const { queryByText, getByPlaceholderText } = render(
|
||||
<GlassInput placeholder="No label input" />
|
||||
);
|
||||
|
||||
expect(getByPlaceholderText('No label input')).toBeTruthy();
|
||||
// No label text should be present
|
||||
});
|
||||
|
||||
it('renders error message when provided', () => {
|
||||
const { getByText } = render(
|
||||
<GlassInput placeholder="Input" error="This field is required" />
|
||||
);
|
||||
|
||||
expect(getByText('This field is required')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not render error message when not provided', () => {
|
||||
const { queryByText, getByPlaceholderText } = render(
|
||||
<GlassInput placeholder="No error input" />
|
||||
);
|
||||
|
||||
expect(getByPlaceholderText('No error input')).toBeTruthy();
|
||||
expect(queryByText(/error/i)).toBeNull();
|
||||
});
|
||||
|
||||
it('renders with icon', () => {
|
||||
const TestIcon = () => <Text testID="input-icon">Icon</Text>;
|
||||
const { getByTestId } = render(
|
||||
<GlassInput placeholder="Input with icon" icon={<TestIcon />} />
|
||||
);
|
||||
|
||||
expect(getByTestId('input-icon')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('platform behavior', () => {
|
||||
it('uses BlurView on iOS', () => {
|
||||
Object.defineProperty(Platform, 'OS', {
|
||||
get: () => 'ios',
|
||||
});
|
||||
|
||||
const { getByTestId } = render(
|
||||
<GlassInput placeholder="iOS Input" />
|
||||
);
|
||||
|
||||
expect(getByTestId('blur-view')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Note: Testing Android platform requires component re-evaluation
|
||||
// which is complex with module-level Platform checks
|
||||
it('renders correctly', () => {
|
||||
const { getByPlaceholderText } = render(
|
||||
<GlassInput placeholder="Input" />
|
||||
);
|
||||
|
||||
expect(getByPlaceholderText('Input')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('input interactions', () => {
|
||||
it('handles text input changes', () => {
|
||||
const onChangeText = jest.fn();
|
||||
const { getByPlaceholderText } = render(
|
||||
<GlassInput placeholder="Type here" onChangeText={onChangeText} />
|
||||
);
|
||||
|
||||
const input = getByPlaceholderText('Type here');
|
||||
fireEvent.changeText(input, 'Hello World');
|
||||
|
||||
expect(onChangeText).toHaveBeenCalledWith('Hello World');
|
||||
});
|
||||
|
||||
it('handles focus events', () => {
|
||||
const onFocus = jest.fn();
|
||||
const { getByPlaceholderText } = render(
|
||||
<GlassInput placeholder="Focus me" onFocus={onFocus} />
|
||||
);
|
||||
|
||||
const input = getByPlaceholderText('Focus me');
|
||||
fireEvent(input, 'focus', {});
|
||||
|
||||
expect(onFocus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles blur events', () => {
|
||||
const onBlur = jest.fn();
|
||||
const { getByPlaceholderText } = render(
|
||||
<GlassInput placeholder="Blur me" onBlur={onBlur} />
|
||||
);
|
||||
|
||||
const input = getByPlaceholderText('Blur me');
|
||||
fireEvent(input, 'blur', {});
|
||||
|
||||
expect(onBlur).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('displays value prop', () => {
|
||||
const { getByDisplayValue } = render(
|
||||
<GlassInput placeholder="Input" value="Initial value" />
|
||||
);
|
||||
|
||||
expect(getByDisplayValue('Initial value')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('handles controlled input', () => {
|
||||
const onChangeText = jest.fn();
|
||||
const { getByDisplayValue, rerender } = render(
|
||||
<GlassInput
|
||||
placeholder="Controlled"
|
||||
value="Initial"
|
||||
onChangeText={onChangeText}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(getByDisplayValue('Initial')).toBeTruthy();
|
||||
|
||||
rerender(
|
||||
<GlassInput
|
||||
placeholder="Controlled"
|
||||
value="Updated"
|
||||
onChangeText={onChangeText}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(getByDisplayValue('Updated')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('focus state styling', () => {
|
||||
it('updates styling on focus', () => {
|
||||
const { getByPlaceholderText } = render(
|
||||
<GlassInput placeholder="Focus styling" />
|
||||
);
|
||||
|
||||
const input = getByPlaceholderText('Focus styling');
|
||||
|
||||
// Trigger focus
|
||||
fireEvent(input, 'focus', {});
|
||||
|
||||
// The component should update without errors
|
||||
expect(input).toBeTruthy();
|
||||
});
|
||||
|
||||
it('updates styling on blur', () => {
|
||||
const { getByPlaceholderText } = render(
|
||||
<GlassInput placeholder="Blur styling" />
|
||||
);
|
||||
|
||||
const input = getByPlaceholderText('Blur styling');
|
||||
|
||||
// Trigger focus then blur
|
||||
fireEvent(input, 'focus', {});
|
||||
fireEvent(input, 'blur', {});
|
||||
|
||||
expect(input).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error state', () => {
|
||||
it('displays error styling when error prop is provided', () => {
|
||||
const { getByText, getByPlaceholderText } = render(
|
||||
<GlassInput
|
||||
placeholder="Error input"
|
||||
error="Invalid input"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(getByPlaceholderText('Error input')).toBeTruthy();
|
||||
expect(getByText('Invalid input')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('applies error border color', () => {
|
||||
const { getByPlaceholderText } = render(
|
||||
<GlassInput placeholder="Error border" error="Has error" />
|
||||
);
|
||||
|
||||
// The component renders with error styling
|
||||
expect(getByPlaceholderText('Error border')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('input props passthrough', () => {
|
||||
it('passes secureTextEntry prop', () => {
|
||||
const { getByPlaceholderText } = render(
|
||||
<GlassInput placeholder="Password" secureTextEntry />
|
||||
);
|
||||
|
||||
const input = getByPlaceholderText('Password');
|
||||
expect(input.props.secureTextEntry).toBe(true);
|
||||
});
|
||||
|
||||
it('passes keyboardType prop', () => {
|
||||
const { getByPlaceholderText } = render(
|
||||
<GlassInput placeholder="Email" keyboardType="email-address" />
|
||||
);
|
||||
|
||||
const input = getByPlaceholderText('Email');
|
||||
expect(input.props.keyboardType).toBe('email-address');
|
||||
});
|
||||
|
||||
it('passes autoCapitalize prop', () => {
|
||||
const { getByPlaceholderText } = render(
|
||||
<GlassInput placeholder="Name" autoCapitalize="words" />
|
||||
);
|
||||
|
||||
const input = getByPlaceholderText('Name');
|
||||
expect(input.props.autoCapitalize).toBe('words');
|
||||
});
|
||||
|
||||
it('passes autoCorrect prop', () => {
|
||||
const { getByPlaceholderText } = render(
|
||||
<GlassInput placeholder="Code" autoCorrect={false} />
|
||||
);
|
||||
|
||||
const input = getByPlaceholderText('Code');
|
||||
expect(input.props.autoCorrect).toBe(false);
|
||||
});
|
||||
|
||||
it('passes maxLength prop', () => {
|
||||
const { getByPlaceholderText } = render(
|
||||
<GlassInput placeholder="Short" maxLength={10} />
|
||||
);
|
||||
|
||||
const input = getByPlaceholderText('Short');
|
||||
expect(input.props.maxLength).toBe(10);
|
||||
});
|
||||
|
||||
it('passes multiline prop', () => {
|
||||
const { getByPlaceholderText } = render(
|
||||
<GlassInput placeholder="Message" multiline />
|
||||
);
|
||||
|
||||
const input = getByPlaceholderText('Message');
|
||||
expect(input.props.multiline).toBe(true);
|
||||
});
|
||||
|
||||
it('passes editable prop', () => {
|
||||
const { getByPlaceholderText } = render(
|
||||
<GlassInput placeholder="Read only" editable={false} />
|
||||
);
|
||||
|
||||
const input = getByPlaceholderText('Read only');
|
||||
expect(input.props.editable).toBe(false);
|
||||
});
|
||||
|
||||
it('passes returnKeyType prop', () => {
|
||||
const { getByPlaceholderText } = render(
|
||||
<GlassInput placeholder="Search" returnKeyType="search" />
|
||||
);
|
||||
|
||||
const input = getByPlaceholderText('Search');
|
||||
expect(input.props.returnKeyType).toBe('search');
|
||||
});
|
||||
});
|
||||
|
||||
describe('containerStyle prop', () => {
|
||||
it('applies custom container style', () => {
|
||||
const customStyle = { marginTop: 20 };
|
||||
const { getByPlaceholderText } = render(
|
||||
<GlassInput
|
||||
placeholder="Styled container"
|
||||
containerStyle={customStyle}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(getByPlaceholderText('Styled container')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('supports accessibilityLabel', () => {
|
||||
const { getByLabelText } = render(
|
||||
<GlassInput
|
||||
placeholder="Accessible input"
|
||||
accessibilityLabel="Username input field"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(getByLabelText('Username input field')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('supports accessibilityHint', () => {
|
||||
const { getByPlaceholderText } = render(
|
||||
<GlassInput
|
||||
placeholder="Hint input"
|
||||
accessibilityHint="Enter your username"
|
||||
/>
|
||||
);
|
||||
|
||||
const input = getByPlaceholderText('Hint input');
|
||||
expect(input.props.accessibilityHint).toBe('Enter your username');
|
||||
});
|
||||
});
|
||||
|
||||
describe('combination of props', () => {
|
||||
it('handles all props together', () => {
|
||||
const onChangeText = jest.fn();
|
||||
const onFocus = jest.fn();
|
||||
const TestIcon = () => <Text>Icon</Text>;
|
||||
|
||||
const { getByText, getByPlaceholderText } = render(
|
||||
<GlassInput
|
||||
label="Full Props Input"
|
||||
placeholder="Enter value"
|
||||
error="Validation error"
|
||||
icon={<TestIcon />}
|
||||
value="Initial"
|
||||
onChangeText={onChangeText}
|
||||
onFocus={onFocus}
|
||||
secureTextEntry
|
||||
keyboardType="default"
|
||||
containerStyle={{ margin: 10 }}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(getByText('Full Props Input')).toBeTruthy();
|
||||
expect(getByPlaceholderText('Enter value')).toBeTruthy();
|
||||
expect(getByText('Validation error')).toBeTruthy();
|
||||
expect(getByText('Icon')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react-native';
|
||||
import { LoadingOverlay } from '@/components/ui/LoadingOverlay';
|
||||
|
||||
describe('LoadingOverlay', () => {
|
||||
describe('visibility', () => {
|
||||
it('returns null when not visible', () => {
|
||||
const { queryByText, toJSON } = render(
|
||||
<LoadingOverlay visible={false} statusText="Loading..." />
|
||||
);
|
||||
|
||||
expect(queryByText('Loading...')).toBeNull();
|
||||
expect(toJSON()).toBeNull();
|
||||
});
|
||||
|
||||
it('renders when visible', () => {
|
||||
const { getByText } = render(
|
||||
<LoadingOverlay visible={true} statusText="Loading..." />
|
||||
);
|
||||
|
||||
expect(getByText('Loading...')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('spinner variant (default)', () => {
|
||||
it('renders spinner by default', () => {
|
||||
const { UNSAFE_getByType } = render(
|
||||
<LoadingOverlay visible={true} />
|
||||
);
|
||||
|
||||
const ActivityIndicator = require('react-native').ActivityIndicator;
|
||||
expect(UNSAFE_getByType(ActivityIndicator)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders status text', () => {
|
||||
const { getByText } = render(
|
||||
<LoadingOverlay visible={true} statusText="Please wait..." />
|
||||
);
|
||||
|
||||
expect(getByText('Please wait...')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders detail text', () => {
|
||||
const { getByText } = render(
|
||||
<LoadingOverlay visible={true} detailText="This may take a moment" />
|
||||
);
|
||||
|
||||
expect(getByText('This may take a moment')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders both status and detail text', () => {
|
||||
const { getByText } = render(
|
||||
<LoadingOverlay
|
||||
visible={true}
|
||||
statusText="Uploading..."
|
||||
detailText="50% complete"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(getByText('Uploading...')).toBeTruthy();
|
||||
expect(getByText('50% complete')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('shimmer variant', () => {
|
||||
it('renders shimmer variant', () => {
|
||||
const { UNSAFE_getByType, getByText } = render(
|
||||
<LoadingOverlay visible={true} variant="shimmer" statusText="Loading..." />
|
||||
);
|
||||
|
||||
const ActivityIndicator = require('react-native').ActivityIndicator;
|
||||
expect(UNSAFE_getByType(ActivityIndicator)).toBeTruthy();
|
||||
expect(getByText('Loading...')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders shimmer with detail text', () => {
|
||||
const { getByText } = render(
|
||||
<LoadingOverlay
|
||||
visible={true}
|
||||
variant="shimmer"
|
||||
statusText="Syncing"
|
||||
detailText="Almost done"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(getByText('Syncing')).toBeTruthy();
|
||||
expect(getByText('Almost done')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('progress variant', () => {
|
||||
it('renders progress variant', () => {
|
||||
const { getByText } = render(
|
||||
<LoadingOverlay
|
||||
visible={true}
|
||||
variant="progress"
|
||||
progress={50}
|
||||
statusText="Uploading..."
|
||||
/>
|
||||
);
|
||||
|
||||
expect(getByText('Uploading...')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders progress with detail text', () => {
|
||||
const { getByText } = render(
|
||||
<LoadingOverlay
|
||||
visible={true}
|
||||
variant="progress"
|
||||
progress={75}
|
||||
statusText="Processing"
|
||||
detailText="3 of 4 complete"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(getByText('Processing')).toBeTruthy();
|
||||
expect(getByText('3 of 4 complete')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('clamps progress to 0-100', () => {
|
||||
// Progress value should be clamped internally
|
||||
const { toJSON } = render(
|
||||
<LoadingOverlay visible={true} variant="progress" progress={150} />
|
||||
);
|
||||
|
||||
// Should render without error
|
||||
expect(toJSON()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('handles negative progress', () => {
|
||||
const { toJSON } = render(
|
||||
<LoadingOverlay visible={true} variant="progress" progress={-10} />
|
||||
);
|
||||
|
||||
// Should render without error
|
||||
expect(toJSON()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fullScreen mode', () => {
|
||||
it('renders in fullScreen mode', () => {
|
||||
const { getByText } = render(
|
||||
<LoadingOverlay
|
||||
visible={true}
|
||||
fullScreen={true}
|
||||
statusText="Full screen loading"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(getByText('Full screen loading')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders fullScreen with different variants', () => {
|
||||
const { rerender, getByText } = render(
|
||||
<LoadingOverlay
|
||||
visible={true}
|
||||
fullScreen={true}
|
||||
variant="spinner"
|
||||
statusText="Spinner"
|
||||
/>
|
||||
);
|
||||
expect(getByText('Spinner')).toBeTruthy();
|
||||
|
||||
rerender(
|
||||
<LoadingOverlay
|
||||
visible={true}
|
||||
fullScreen={true}
|
||||
variant="progress"
|
||||
statusText="Progress"
|
||||
/>
|
||||
);
|
||||
expect(getByText('Progress')).toBeTruthy();
|
||||
|
||||
rerender(
|
||||
<LoadingOverlay
|
||||
visible={true}
|
||||
fullScreen={true}
|
||||
variant="shimmer"
|
||||
statusText="Shimmer"
|
||||
/>
|
||||
);
|
||||
expect(getByText('Shimmer')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom styling', () => {
|
||||
it('applies custom style', () => {
|
||||
const customStyle = { marginTop: 20 };
|
||||
const { getByText } = render(
|
||||
<LoadingOverlay
|
||||
visible={true}
|
||||
style={customStyle}
|
||||
statusText="Styled"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(getByText('Styled')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('applies custom text style', () => {
|
||||
const customTextStyle = { fontSize: 20 };
|
||||
const { getByText } = render(
|
||||
<LoadingOverlay
|
||||
visible={true}
|
||||
textStyle={customTextStyle}
|
||||
statusText="Custom text"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(getByText('Custom text')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('uses custom spinner color', () => {
|
||||
const { UNSAFE_getByType } = render(
|
||||
<LoadingOverlay
|
||||
visible={true}
|
||||
spinnerColor="#FF0000"
|
||||
/>
|
||||
);
|
||||
|
||||
const ActivityIndicator = require('react-native').ActivityIndicator;
|
||||
const indicator = UNSAFE_getByType(ActivityIndicator);
|
||||
expect(indicator.props.color).toBe('#FF0000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('default props', () => {
|
||||
it('has spinner variant by default', () => {
|
||||
const { UNSAFE_getByType } = render(
|
||||
<LoadingOverlay visible={true} />
|
||||
);
|
||||
|
||||
const ActivityIndicator = require('react-native').ActivityIndicator;
|
||||
expect(UNSAFE_getByType(ActivityIndicator)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('has fullScreen false by default', () => {
|
||||
const { toJSON } = render(
|
||||
<LoadingOverlay visible={true} statusText="Test" />
|
||||
);
|
||||
|
||||
// Should render in non-fullscreen mode
|
||||
expect(toJSON()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('has progress 0 by default', () => {
|
||||
const { toJSON } = render(
|
||||
<LoadingOverlay visible={true} variant="progress" />
|
||||
);
|
||||
|
||||
// Should render without error
|
||||
expect(toJSON()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react-native';
|
||||
import { Text, View } from 'react-native';
|
||||
import { ShimmerEffect } from '@/components/ui/ShimmerEffect';
|
||||
|
||||
describe('ShimmerEffect', () => {
|
||||
describe('rendering', () => {
|
||||
it('renders children correctly', () => {
|
||||
const { getByText } = render(
|
||||
<ShimmerEffect>
|
||||
<Text>Child Content</Text>
|
||||
</ShimmerEffect>
|
||||
);
|
||||
|
||||
expect(getByText('Child Content')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders multiple children', () => {
|
||||
const { getByText } = render(
|
||||
<ShimmerEffect>
|
||||
<Text>First Child</Text>
|
||||
<Text>Second Child</Text>
|
||||
</ShimmerEffect>
|
||||
);
|
||||
|
||||
expect(getByText('First Child')).toBeTruthy();
|
||||
expect(getByText('Second Child')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders nested components', () => {
|
||||
const { getByText, getByTestId } = render(
|
||||
<ShimmerEffect>
|
||||
<View testID="nested-view">
|
||||
<Text>Nested Content</Text>
|
||||
</View>
|
||||
</ShimmerEffect>
|
||||
);
|
||||
|
||||
expect(getByTestId('nested-view')).toBeTruthy();
|
||||
expect(getByText('Nested Content')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('enabled prop', () => {
|
||||
it('shows glow effect when enabled (default)', () => {
|
||||
const { toJSON } = render(
|
||||
<ShimmerEffect>
|
||||
<Text>Content</Text>
|
||||
</ShimmerEffect>
|
||||
);
|
||||
|
||||
// Should render with glow elements
|
||||
expect(toJSON()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('hides glow effect when disabled', () => {
|
||||
const { getByText, toJSON } = render(
|
||||
<ShimmerEffect enabled={false}>
|
||||
<Text>Content</Text>
|
||||
</ShimmerEffect>
|
||||
);
|
||||
|
||||
expect(getByText('Content')).toBeTruthy();
|
||||
// The glow container should not be rendered when disabled
|
||||
expect(toJSON()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('toggles enabled state', () => {
|
||||
const { rerender, getByText } = render(
|
||||
<ShimmerEffect enabled={true}>
|
||||
<Text>Content</Text>
|
||||
</ShimmerEffect>
|
||||
);
|
||||
expect(getByText('Content')).toBeTruthy();
|
||||
|
||||
rerender(
|
||||
<ShimmerEffect enabled={false}>
|
||||
<Text>Content</Text>
|
||||
</ShimmerEffect>
|
||||
);
|
||||
expect(getByText('Content')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom styling', () => {
|
||||
it('applies custom style', () => {
|
||||
const customStyle = { margin: 10, padding: 20 };
|
||||
const { getByText } = render(
|
||||
<ShimmerEffect style={customStyle}>
|
||||
<Text>Styled Content</Text>
|
||||
</ShimmerEffect>
|
||||
);
|
||||
|
||||
expect(getByText('Styled Content')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('uses custom shimmer color', () => {
|
||||
const { getByText } = render(
|
||||
<ShimmerEffect shimmerColor="#FF0000">
|
||||
<Text>Red Shimmer</Text>
|
||||
</ShimmerEffect>
|
||||
);
|
||||
|
||||
expect(getByText('Red Shimmer')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('duration prop', () => {
|
||||
it('accepts custom duration', () => {
|
||||
const { getByText } = render(
|
||||
<ShimmerEffect duration={2000}>
|
||||
<Text>Fast Shimmer</Text>
|
||||
</ShimmerEffect>
|
||||
);
|
||||
|
||||
expect(getByText('Fast Shimmer')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('uses default duration of 4000ms', () => {
|
||||
const { getByText } = render(
|
||||
<ShimmerEffect>
|
||||
<Text>Default Duration</Text>
|
||||
</ShimmerEffect>
|
||||
);
|
||||
|
||||
expect(getByText('Default Duration')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('default props', () => {
|
||||
it('has enabled true by default', () => {
|
||||
const { toJSON, getByText } = render(
|
||||
<ShimmerEffect>
|
||||
<Text>Content</Text>
|
||||
</ShimmerEffect>
|
||||
);
|
||||
|
||||
expect(getByText('Content')).toBeTruthy();
|
||||
expect(toJSON()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('uses default shimmer color', () => {
|
||||
const { getByText } = render(
|
||||
<ShimmerEffect>
|
||||
<Text>Default Color</Text>
|
||||
</ShimmerEffect>
|
||||
);
|
||||
|
||||
expect(getByText('Default Color')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('composition', () => {
|
||||
it('works with complex children', () => {
|
||||
const { getByText, getByTestId } = render(
|
||||
<ShimmerEffect>
|
||||
<View testID="outer">
|
||||
<Text>Header</Text>
|
||||
<View testID="inner">
|
||||
<Text>Body</Text>
|
||||
</View>
|
||||
<Text>Footer</Text>
|
||||
</View>
|
||||
</ShimmerEffect>
|
||||
);
|
||||
|
||||
expect(getByTestId('outer')).toBeTruthy();
|
||||
expect(getByTestId('inner')).toBeTruthy();
|
||||
expect(getByText('Header')).toBeTruthy();
|
||||
expect(getByText('Body')).toBeTruthy();
|
||||
expect(getByText('Footer')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('can be nested', () => {
|
||||
const { getByText } = render(
|
||||
<ShimmerEffect shimmerColor="#FF0000">
|
||||
<ShimmerEffect shimmerColor="#00FF00" enabled={false}>
|
||||
<Text>Nested Shimmer</Text>
|
||||
</ShimmerEffect>
|
||||
</ShimmerEffect>
|
||||
);
|
||||
|
||||
expect(getByText('Nested Shimmer')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { renderHook, act, waitFor } from '@testing-library/react-native';
|
||||
import { useFeatureInterest } from '@/hooks/useFeatureInterest';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
|
||||
function createThenableQuery(result: any) {
|
||||
const query: any = {
|
||||
select: jest.fn(() => query),
|
||||
eq: jest.fn(() => query),
|
||||
insert: jest.fn(() => query),
|
||||
delete: jest.fn(() => query),
|
||||
maybeSingle: jest.fn(() => query),
|
||||
then: (resolve: any, reject: any) => Promise.resolve(result).then(resolve, reject),
|
||||
};
|
||||
return query;
|
||||
}
|
||||
|
||||
describe('useFeatureInterest', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useAuthStore.setState({ user: { id: 'user1' } } as any);
|
||||
});
|
||||
|
||||
it('fetches initial interest status as true when interest exists', async () => {
|
||||
const query = createThenableQuery({ data: { id: 'interest1' } });
|
||||
(supabase.from as jest.Mock).mockReturnValue(query);
|
||||
|
||||
const { result } = renderHook(() => useFeatureInterest('dares'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.isInterested).toBe(true);
|
||||
expect(result.current.isAuthenticated).toBe(true);
|
||||
expect(query.eq).toHaveBeenCalledWith('user_id', 'user1');
|
||||
expect(query.eq).toHaveBeenCalledWith('feature_name', 'dares');
|
||||
});
|
||||
|
||||
it('fetches initial interest status as false when no interest exists', async () => {
|
||||
const query = createThenableQuery({ data: null });
|
||||
(supabase.from as jest.Mock).mockReturnValue(query);
|
||||
|
||||
const { result } = renderHook(() => useFeatureInterest('dares'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.isInterested).toBe(false);
|
||||
});
|
||||
|
||||
it('returns isAuthenticated false when user is null', async () => {
|
||||
useAuthStore.setState({ user: null } as any);
|
||||
|
||||
const { result } = renderHook(() => useFeatureInterest('dares'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.isAuthenticated).toBe(false);
|
||||
expect(result.current.isInterested).toBe(false);
|
||||
expect(supabase.from).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adds interest when toggling from not interested', async () => {
|
||||
// Initial query returns no interest
|
||||
const initialQuery = createThenableQuery({ data: null });
|
||||
const insertQuery = createThenableQuery({ error: null });
|
||||
|
||||
(supabase.from as jest.Mock)
|
||||
.mockReturnValueOnce(initialQuery) // Initial fetch
|
||||
.mockReturnValueOnce(insertQuery); // Insert on toggle
|
||||
|
||||
const { result } = renderHook(() => useFeatureInterest('dares'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.isInterested).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.toggleInterest();
|
||||
});
|
||||
|
||||
expect(result.current.isInterested).toBe(true);
|
||||
expect(insertQuery.insert).toHaveBeenCalledWith({
|
||||
user_id: 'user1',
|
||||
feature_name: 'dares',
|
||||
});
|
||||
});
|
||||
|
||||
it('removes interest when toggling from interested', async () => {
|
||||
// Initial query returns existing interest
|
||||
const initialQuery = createThenableQuery({ data: { id: 'interest1' } });
|
||||
const deleteQuery = createThenableQuery({ error: null });
|
||||
|
||||
(supabase.from as jest.Mock)
|
||||
.mockReturnValueOnce(initialQuery) // Initial fetch
|
||||
.mockReturnValueOnce(deleteQuery); // Delete on toggle
|
||||
|
||||
const { result } = renderHook(() => useFeatureInterest('dares'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.isInterested).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.toggleInterest();
|
||||
});
|
||||
|
||||
expect(result.current.isInterested).toBe(false);
|
||||
expect(deleteQuery.delete).toHaveBeenCalled();
|
||||
expect(deleteQuery.eq).toHaveBeenCalledWith('user_id', 'user1');
|
||||
expect(deleteQuery.eq).toHaveBeenCalledWith('feature_name', 'dares');
|
||||
});
|
||||
|
||||
it('reverts optimistic update on error', async () => {
|
||||
const initialQuery = createThenableQuery({ data: null });
|
||||
const insertQuery = createThenableQuery({ error: new Error('Insert failed') });
|
||||
|
||||
(supabase.from as jest.Mock)
|
||||
.mockReturnValueOnce(initialQuery)
|
||||
.mockReturnValueOnce(insertQuery);
|
||||
|
||||
const { result } = renderHook(() => useFeatureInterest('dares'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.isInterested).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.toggleInterest();
|
||||
});
|
||||
|
||||
// Should revert to original state after error
|
||||
expect(result.current.isInterested).toBe(false);
|
||||
});
|
||||
|
||||
it('does not toggle when user is not authenticated', async () => {
|
||||
useAuthStore.setState({ user: null } as any);
|
||||
|
||||
const { result } = renderHook(() => useFeatureInterest('dares'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.toggleInterest();
|
||||
});
|
||||
|
||||
// Should not make any API calls
|
||||
expect(supabase.from).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('isToggling is false when not toggling', async () => {
|
||||
const initialQuery = createThenableQuery({ data: null });
|
||||
|
||||
(supabase.from as jest.Mock).mockReturnValueOnce(initialQuery);
|
||||
|
||||
const { result } = renderHook(() => useFeatureInterest('dares'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.isToggling).toBe(false);
|
||||
});
|
||||
|
||||
it('isToggling is false after toggle completes', async () => {
|
||||
const initialQuery = createThenableQuery({ data: null });
|
||||
const insertQuery = createThenableQuery({ error: null });
|
||||
|
||||
(supabase.from as jest.Mock)
|
||||
.mockReturnValueOnce(initialQuery)
|
||||
.mockReturnValueOnce(insertQuery);
|
||||
|
||||
const { result } = renderHook(() => useFeatureInterest('dares'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.toggleInterest();
|
||||
});
|
||||
|
||||
expect(result.current.isToggling).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -6,91 +6,355 @@ import { useMediaPicker } from '@/hooks/useMediaPicker';
|
||||
describe('useMediaPicker', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
it('returns null when user cancels library picker', async () => {
|
||||
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValueOnce({
|
||||
canceled: true,
|
||||
assets: [],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMediaPicker());
|
||||
|
||||
let picked: any;
|
||||
await act(async () => {
|
||||
picked = await result.current.pickMedia();
|
||||
});
|
||||
|
||||
expect(picked).toBeNull();
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('maps selected image to MediaResult', async () => {
|
||||
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValueOnce({
|
||||
canceled: false,
|
||||
assets: [{
|
||||
describe('pickMedia', () => {
|
||||
it('returns null when user cancels library picker', async () => {
|
||||
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValueOnce({
|
||||
canceled: true,
|
||||
assets: [],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMediaPicker());
|
||||
|
||||
let picked: any;
|
||||
await act(async () => {
|
||||
picked = await result.current.pickMedia();
|
||||
});
|
||||
|
||||
expect(picked).toBeNull();
|
||||
});
|
||||
|
||||
it('maps selected image to MediaResult', async () => {
|
||||
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValueOnce({
|
||||
canceled: false,
|
||||
assets: [{
|
||||
uri: 'file://image.jpg',
|
||||
type: 'image',
|
||||
width: 100,
|
||||
height: 200,
|
||||
}],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMediaPicker());
|
||||
|
||||
let picked: any;
|
||||
await act(async () => {
|
||||
picked = await result.current.pickMedia();
|
||||
});
|
||||
|
||||
expect(picked).toEqual({
|
||||
uri: 'file://image.jpg',
|
||||
type: 'image',
|
||||
mediaType: 'image',
|
||||
width: 100,
|
||||
height: 200,
|
||||
}],
|
||||
duration: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMediaPicker());
|
||||
it('maps selected video to MediaResult', async () => {
|
||||
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValueOnce({
|
||||
canceled: false,
|
||||
assets: [{
|
||||
uri: 'file://video.mp4',
|
||||
type: 'video',
|
||||
width: 640,
|
||||
height: 480,
|
||||
duration: 1234,
|
||||
}],
|
||||
});
|
||||
|
||||
let picked: any;
|
||||
await act(async () => {
|
||||
picked = await result.current.pickMedia();
|
||||
});
|
||||
const { result } = renderHook(() => useMediaPicker());
|
||||
|
||||
expect(picked).toEqual({
|
||||
uri: 'file://image.jpg',
|
||||
mediaType: 'image',
|
||||
width: 100,
|
||||
height: 200,
|
||||
duration: undefined,
|
||||
});
|
||||
});
|
||||
let picked: any;
|
||||
await act(async () => {
|
||||
picked = await result.current.pickMedia();
|
||||
});
|
||||
|
||||
it('maps selected video to MediaResult', async () => {
|
||||
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValueOnce({
|
||||
canceled: false,
|
||||
assets: [{
|
||||
expect(picked).toEqual({
|
||||
uri: 'file://video.mp4',
|
||||
type: 'video',
|
||||
mediaType: 'video',
|
||||
width: 640,
|
||||
height: 480,
|
||||
duration: 1234,
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMediaPicker());
|
||||
it('uses custom config options', async () => {
|
||||
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValueOnce({
|
||||
canceled: true,
|
||||
});
|
||||
|
||||
let picked: any;
|
||||
await act(async () => {
|
||||
picked = await result.current.pickMedia();
|
||||
});
|
||||
const { result } = renderHook(() => useMediaPicker({
|
||||
imageQuality: 0.5,
|
||||
libraryVideoMaxDuration: 120,
|
||||
}));
|
||||
|
||||
expect(picked).toEqual({
|
||||
uri: 'file://video.mp4',
|
||||
mediaType: 'video',
|
||||
width: 640,
|
||||
height: 480,
|
||||
duration: 1234,
|
||||
await act(async () => {
|
||||
await result.current.pickMedia();
|
||||
});
|
||||
|
||||
expect(ImagePicker.launchImageLibraryAsync).toHaveBeenCalledWith({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.All,
|
||||
quality: 0.5,
|
||||
videoMaxDuration: 120,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('alerts and returns false when camera permission denied', async () => {
|
||||
const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {});
|
||||
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'denied' });
|
||||
describe('requestCameraPermission', () => {
|
||||
it('returns true when permission granted', async () => {
|
||||
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'granted' });
|
||||
|
||||
const { result } = renderHook(() => useMediaPicker());
|
||||
const { result } = renderHook(() => useMediaPicker());
|
||||
|
||||
let ok: any;
|
||||
await act(async () => {
|
||||
ok = await result.current.requestCameraPermission();
|
||||
let ok: any;
|
||||
await act(async () => {
|
||||
ok = await result.current.requestCameraPermission();
|
||||
});
|
||||
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
|
||||
expect(ok).toBe(false);
|
||||
expect(alertSpy).toHaveBeenCalled();
|
||||
it('alerts and returns false when camera permission denied', async () => {
|
||||
const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {});
|
||||
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'denied' });
|
||||
|
||||
const { result } = renderHook(() => useMediaPicker());
|
||||
|
||||
let ok: any;
|
||||
await act(async () => {
|
||||
ok = await result.current.requestCameraPermission();
|
||||
});
|
||||
|
||||
expect(ok).toBe(false);
|
||||
expect(alertSpy).toHaveBeenCalledWith(
|
||||
'Camera Permission',
|
||||
'Please allow camera access to use this feature.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('takePhoto', () => {
|
||||
it('takes photo successfully', async () => {
|
||||
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'granted' });
|
||||
(ImagePicker.launchCameraAsync as jest.Mock).mockResolvedValueOnce({
|
||||
canceled: false,
|
||||
assets: [{
|
||||
uri: 'file://photo.jpg',
|
||||
type: 'image',
|
||||
width: 3024,
|
||||
height: 4032,
|
||||
}],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMediaPicker());
|
||||
|
||||
let photo: any;
|
||||
await act(async () => {
|
||||
photo = await result.current.takePhoto();
|
||||
});
|
||||
|
||||
expect(photo).toEqual({
|
||||
uri: 'file://photo.jpg',
|
||||
mediaType: 'image',
|
||||
width: 3024,
|
||||
height: 4032,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when permission denied', async () => {
|
||||
jest.spyOn(Alert, 'alert').mockImplementation(() => {});
|
||||
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'denied' });
|
||||
|
||||
const { result } = renderHook(() => useMediaPicker());
|
||||
|
||||
let photo: any;
|
||||
await act(async () => {
|
||||
photo = await result.current.takePhoto();
|
||||
});
|
||||
|
||||
expect(photo).toBeNull();
|
||||
expect(ImagePicker.launchCameraAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns null when cancelled', async () => {
|
||||
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'granted' });
|
||||
(ImagePicker.launchCameraAsync as jest.Mock).mockResolvedValueOnce({
|
||||
canceled: true,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMediaPicker());
|
||||
|
||||
let photo: any;
|
||||
await act(async () => {
|
||||
photo = await result.current.takePhoto();
|
||||
});
|
||||
|
||||
expect(photo).toBeNull();
|
||||
});
|
||||
|
||||
it('uses custom image quality', async () => {
|
||||
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'granted' });
|
||||
(ImagePicker.launchCameraAsync as jest.Mock).mockResolvedValueOnce({ canceled: true });
|
||||
|
||||
const { result } = renderHook(() => useMediaPicker({ imageQuality: 0.9 }));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.takePhoto();
|
||||
});
|
||||
|
||||
expect(ImagePicker.launchCameraAsync).toHaveBeenCalledWith({ quality: 0.9 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordVideo', () => {
|
||||
it('records video successfully', async () => {
|
||||
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'granted' });
|
||||
(ImagePicker.launchCameraAsync as jest.Mock).mockResolvedValueOnce({
|
||||
canceled: false,
|
||||
assets: [{
|
||||
uri: 'file://video.mp4',
|
||||
type: 'video',
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
duration: 15000,
|
||||
}],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMediaPicker());
|
||||
|
||||
let video: any;
|
||||
await act(async () => {
|
||||
video = await result.current.recordVideo();
|
||||
});
|
||||
|
||||
expect(video).toEqual({
|
||||
uri: 'file://video.mp4',
|
||||
mediaType: 'video',
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
duration: 15000,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when permission denied', async () => {
|
||||
jest.spyOn(Alert, 'alert').mockImplementation(() => {});
|
||||
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'denied' });
|
||||
|
||||
const { result } = renderHook(() => useMediaPicker());
|
||||
|
||||
let video: any;
|
||||
await act(async () => {
|
||||
video = await result.current.recordVideo();
|
||||
});
|
||||
|
||||
expect(video).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when cancelled', async () => {
|
||||
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'granted' });
|
||||
(ImagePicker.launchCameraAsync as jest.Mock).mockResolvedValueOnce({ canceled: true });
|
||||
|
||||
const { result } = renderHook(() => useMediaPicker());
|
||||
|
||||
let video: any;
|
||||
await act(async () => {
|
||||
video = await result.current.recordVideo();
|
||||
});
|
||||
|
||||
expect(video).toBeNull();
|
||||
});
|
||||
|
||||
it('uses custom video max duration', async () => {
|
||||
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'granted' });
|
||||
(ImagePicker.launchCameraAsync as jest.Mock).mockResolvedValueOnce({ canceled: true });
|
||||
|
||||
const { result } = renderHook(() => useMediaPicker({ cameraVideoMaxDuration: 30 }));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.recordVideo();
|
||||
});
|
||||
|
||||
expect(ImagePicker.launchCameraAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
videoMaxDuration: 30,
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.Videos,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('handles null duration in video result', async () => {
|
||||
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'granted' });
|
||||
(ImagePicker.launchCameraAsync as jest.Mock).mockResolvedValueOnce({
|
||||
canceled: false,
|
||||
assets: [{
|
||||
uri: 'file://video.mp4',
|
||||
type: 'video',
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
duration: null,
|
||||
}],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMediaPicker());
|
||||
|
||||
let video: any;
|
||||
await act(async () => {
|
||||
video = await result.current.recordVideo();
|
||||
});
|
||||
|
||||
expect(video.duration).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('default config', () => {
|
||||
it('uses default image quality of 0.7', async () => {
|
||||
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValueOnce({ canceled: true });
|
||||
|
||||
const { result } = renderHook(() => useMediaPicker());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.pickMedia();
|
||||
});
|
||||
|
||||
expect(ImagePicker.launchImageLibraryAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ quality: 0.7 })
|
||||
);
|
||||
});
|
||||
|
||||
it('uses default library video max duration of 300', async () => {
|
||||
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValueOnce({ canceled: true });
|
||||
|
||||
const { result } = renderHook(() => useMediaPicker());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.pickMedia();
|
||||
});
|
||||
|
||||
expect(ImagePicker.launchImageLibraryAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ videoMaxDuration: 300 })
|
||||
);
|
||||
});
|
||||
|
||||
it('uses default camera video max duration of 60', async () => {
|
||||
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'granted' });
|
||||
(ImagePicker.launchCameraAsync as jest.Mock).mockResolvedValueOnce({ canceled: true });
|
||||
|
||||
const { result } = renderHook(() => useMediaPicker());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.recordVideo();
|
||||
});
|
||||
|
||||
expect(ImagePicker.launchCameraAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ videoMaxDuration: 60 })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import { renderHook, act, waitFor } from '@testing-library/react-native';
|
||||
import { Alert } from 'react-native';
|
||||
import * as MediaLibrary from 'expo-media-library';
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import { useMediaSaver } from '@/hooks/useMediaSaver';
|
||||
|
||||
// Mock expo-media-library
|
||||
jest.mock('expo-media-library', () => ({
|
||||
usePermissions: jest.fn(),
|
||||
createAssetAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock expo-file-system with all required methods
|
||||
jest.mock('expo-file-system', () => ({
|
||||
readAsStringAsync: jest.fn(async () => ''),
|
||||
downloadAsync: jest.fn(),
|
||||
deleteAsync: jest.fn(),
|
||||
cacheDirectory: 'file:///cache/',
|
||||
EncodingType: {
|
||||
Base64: 'base64',
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock Alert
|
||||
jest.spyOn(Alert, 'alert').mockImplementation(() => {});
|
||||
|
||||
describe('useMediaSaver', () => {
|
||||
const mockRequestPermission = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Default: permission granted
|
||||
(MediaLibrary.usePermissions as jest.Mock).mockReturnValue([
|
||||
{ granted: true },
|
||||
mockRequestPermission,
|
||||
]);
|
||||
(MediaLibrary.createAssetAsync as jest.Mock).mockResolvedValue({ id: 'asset1' });
|
||||
(FileSystem.downloadAsync as jest.Mock).mockResolvedValue({
|
||||
uri: 'file:///cache/download_123.jpg',
|
||||
});
|
||||
(FileSystem.deleteAsync as jest.Mock).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('initializes with saving as false', () => {
|
||||
const { result } = renderHook(() => useMediaSaver());
|
||||
|
||||
expect(result.current.saving).toBe(false);
|
||||
expect(typeof result.current.saveMedia).toBe('function');
|
||||
});
|
||||
|
||||
it('saves local image successfully', async () => {
|
||||
const { result } = renderHook(() => useMediaSaver());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveMedia('file:///local/image.jpg', 'image');
|
||||
});
|
||||
|
||||
expect(MediaLibrary.createAssetAsync).toHaveBeenCalledWith('file:///local/image.jpg');
|
||||
expect(Alert.alert).toHaveBeenCalledWith('Saved', 'Image saved to your gallery.');
|
||||
expect(result.current.saving).toBe(false);
|
||||
});
|
||||
|
||||
it('saves local video successfully', async () => {
|
||||
const { result } = renderHook(() => useMediaSaver());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveMedia('file:///local/video.mp4', 'video');
|
||||
});
|
||||
|
||||
expect(MediaLibrary.createAssetAsync).toHaveBeenCalledWith('file:///local/video.mp4');
|
||||
expect(Alert.alert).toHaveBeenCalledWith('Saved', 'Video saved to your gallery.');
|
||||
});
|
||||
|
||||
it('downloads remote image before saving', async () => {
|
||||
const { result } = renderHook(() => useMediaSaver());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveMedia('https://example.com/image.jpg', 'image');
|
||||
});
|
||||
|
||||
expect(FileSystem.downloadAsync).toHaveBeenCalledWith(
|
||||
'https://example.com/image.jpg',
|
||||
expect.stringMatching(/download_\d+\.jpg$/)
|
||||
);
|
||||
expect(MediaLibrary.createAssetAsync).toHaveBeenCalledWith('file:///cache/download_123.jpg');
|
||||
expect(FileSystem.deleteAsync).toHaveBeenCalledWith('file:///cache/download_123.jpg', { idempotent: true });
|
||||
});
|
||||
|
||||
it('downloads remote video before saving', async () => {
|
||||
(FileSystem.downloadAsync as jest.Mock).mockResolvedValue({
|
||||
uri: 'file:///cache/download_123.mp4',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMediaSaver());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveMedia('https://example.com/video.mp4', 'video');
|
||||
});
|
||||
|
||||
expect(FileSystem.downloadAsync).toHaveBeenCalledWith(
|
||||
'https://example.com/video.mp4',
|
||||
expect.stringMatching(/download_\d+\.mp4$/)
|
||||
);
|
||||
expect(MediaLibrary.createAssetAsync).toHaveBeenCalledWith('file:///cache/download_123.mp4');
|
||||
});
|
||||
|
||||
it('handles http URLs (upgrades to https internally)', async () => {
|
||||
const { result } = renderHook(() => useMediaSaver());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveMedia('http://example.com/image.jpg', 'image');
|
||||
});
|
||||
|
||||
// http URLs are also considered remote
|
||||
expect(FileSystem.downloadAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requests permission when not granted', async () => {
|
||||
(MediaLibrary.usePermissions as jest.Mock).mockReturnValue([
|
||||
{ granted: false },
|
||||
mockRequestPermission,
|
||||
]);
|
||||
mockRequestPermission.mockResolvedValue({ granted: true });
|
||||
|
||||
const { result } = renderHook(() => useMediaSaver());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveMedia('file:///local/image.jpg', 'image');
|
||||
});
|
||||
|
||||
expect(mockRequestPermission).toHaveBeenCalled();
|
||||
expect(MediaLibrary.createAssetAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows alert and returns early when permission denied', async () => {
|
||||
(MediaLibrary.usePermissions as jest.Mock).mockReturnValue([
|
||||
{ granted: false },
|
||||
mockRequestPermission,
|
||||
]);
|
||||
mockRequestPermission.mockResolvedValue({ granted: false });
|
||||
|
||||
const { result } = renderHook(() => useMediaSaver());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveMedia('file:///local/image.jpg', 'image');
|
||||
});
|
||||
|
||||
expect(Alert.alert).toHaveBeenCalledWith(
|
||||
'Permission required',
|
||||
'Please allow access to your photo library to save media.'
|
||||
);
|
||||
expect(MediaLibrary.createAssetAsync).not.toHaveBeenCalled();
|
||||
expect(result.current.saving).toBe(false);
|
||||
});
|
||||
|
||||
it('handles save errors gracefully', async () => {
|
||||
(MediaLibrary.createAssetAsync as jest.Mock).mockRejectedValue(new Error('Save failed'));
|
||||
|
||||
const { result } = renderHook(() => useMediaSaver());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveMedia('file:///local/image.jpg', 'image');
|
||||
});
|
||||
|
||||
expect(Alert.alert).toHaveBeenCalledWith('Error', 'Failed to save media.');
|
||||
expect(result.current.saving).toBe(false);
|
||||
});
|
||||
|
||||
it('handles download errors gracefully', async () => {
|
||||
(FileSystem.downloadAsync as jest.Mock).mockRejectedValue(new Error('Download failed'));
|
||||
|
||||
const { result } = renderHook(() => useMediaSaver());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveMedia('https://example.com/image.jpg', 'image');
|
||||
});
|
||||
|
||||
expect(Alert.alert).toHaveBeenCalledWith('Error', 'Failed to save media.');
|
||||
expect(result.current.saving).toBe(false);
|
||||
});
|
||||
|
||||
it('sets saving to true during operation', async () => {
|
||||
let savingDuringOperation = false;
|
||||
|
||||
(MediaLibrary.createAssetAsync as jest.Mock).mockImplementation(async () => {
|
||||
// This would capture the saving state during the async operation
|
||||
// but since we can't access result.current here synchronously,
|
||||
// we'll test this differently
|
||||
return { id: 'asset1' };
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMediaSaver());
|
||||
|
||||
const savePromise = act(async () => {
|
||||
// Check saving is true after calling but before completion
|
||||
const promise = result.current.saveMedia('file:///local/image.jpg', 'image');
|
||||
// Can't reliably check intermediate state, so we just verify final state
|
||||
await promise;
|
||||
});
|
||||
|
||||
await savePromise;
|
||||
expect(result.current.saving).toBe(false);
|
||||
});
|
||||
|
||||
it('does not delete local files after saving', async () => {
|
||||
const { result } = renderHook(() => useMediaSaver());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveMedia('file:///local/image.jpg', 'image');
|
||||
});
|
||||
|
||||
expect(FileSystem.deleteAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cleans up downloaded file after saving remote URL', async () => {
|
||||
(FileSystem.downloadAsync as jest.Mock).mockResolvedValue({
|
||||
uri: 'file:///cache/download_123.jpg',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMediaSaver());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveMedia('https://example.com/image.jpg', 'image');
|
||||
});
|
||||
|
||||
expect(FileSystem.deleteAsync).toHaveBeenCalledWith(
|
||||
'file:///cache/download_123.jpg',
|
||||
{ idempotent: true }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,9 @@ function createThenableQuery(result: any) {
|
||||
in: jest.fn(() => query),
|
||||
order: jest.fn(() => query),
|
||||
range: jest.fn(() => query),
|
||||
update: jest.fn(() => query),
|
||||
insert: jest.fn(() => query),
|
||||
delete: jest.fn(() => query),
|
||||
then: (resolve: any, reject: any) => Promise.resolve(result).then(resolve, reject),
|
||||
};
|
||||
return query;
|
||||
@@ -19,31 +22,464 @@ function createThenableQuery(result: any) {
|
||||
describe('matchStore', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useMatchStore.setState({ matches: [], newMatchesCount: 0, isLoading: false, error: null } as any);
|
||||
useMatchStore.setState({
|
||||
matches: [],
|
||||
newMatchesCount: 0,
|
||||
totalCount: null,
|
||||
isLoading: false,
|
||||
isLoadingMore: false,
|
||||
page: 0,
|
||||
hasMore: true,
|
||||
error: null,
|
||||
archivedMatches: [],
|
||||
archivedMatchIds: new Set<string>(),
|
||||
showArchived: false,
|
||||
isLoadingArchived: false,
|
||||
});
|
||||
useAuthStore.setState({ user: { id: 'me', couple_id: 'couple1' } } as any);
|
||||
});
|
||||
|
||||
it('sorts matches with unread messages first', async () => {
|
||||
const matches = [
|
||||
{ id: 'a', question_id: 'q1', created_at: '2024-01-02T00:00:00.000Z', is_new: true },
|
||||
{ id: 'b', question_id: 'q2', created_at: '2024-01-03T00:00:00.000Z', is_new: false },
|
||||
];
|
||||
describe('fetchMatches', () => {
|
||||
it('sorts matches with unread messages first', async () => {
|
||||
const matches = [
|
||||
{ id: 'a', question_id: 'q1', created_at: '2024-01-02T00:00:00.000Z', is_new: true },
|
||||
{ id: 'b', question_id: 'q2', created_at: '2024-01-03T00:00:00.000Z', is_new: false },
|
||||
];
|
||||
|
||||
const unreadMessages = [{ match_id: 'a' }, { match_id: 'a' }];
|
||||
const unreadMessages = [{ match_id: 'a' }, { match_id: 'a' }];
|
||||
|
||||
const matchesQuery = createThenableQuery({ data: matches });
|
||||
const responsesQuery = createThenableQuery({ data: [] });
|
||||
const unreadQuery = createThenableQuery({ data: unreadMessages, error: null });
|
||||
const archivesQuery = createThenableQuery({ data: [] });
|
||||
const countQuery = createThenableQuery({ count: 2 });
|
||||
const matchesQuery = createThenableQuery({ data: matches });
|
||||
const responsesQuery = createThenableQuery({ data: [] });
|
||||
const unreadQuery = createThenableQuery({ data: unreadMessages, error: null });
|
||||
|
||||
(supabase.from as jest.Mock)
|
||||
.mockReturnValueOnce(matchesQuery) // matches
|
||||
.mockReturnValueOnce(responsesQuery) // responses
|
||||
.mockReturnValueOnce(unreadQuery); // messages
|
||||
(supabase.from as jest.Mock)
|
||||
.mockReturnValueOnce(archivesQuery) // archived matches
|
||||
.mockReturnValueOnce(countQuery) // count
|
||||
.mockReturnValueOnce(matchesQuery) // matches
|
||||
.mockReturnValueOnce(responsesQuery) // responses
|
||||
.mockReturnValueOnce(unreadQuery); // messages
|
||||
|
||||
await useMatchStore.getState().fetchMatches();
|
||||
await useMatchStore.getState().fetchMatches(true);
|
||||
|
||||
const state = useMatchStore.getState();
|
||||
expect(state.matches[0].id).toBe('a');
|
||||
expect(state.newMatchesCount).toBe(1);
|
||||
const state = useMatchStore.getState();
|
||||
expect(state.matches[0].id).toBe('a');
|
||||
expect(state.newMatchesCount).toBe(1);
|
||||
});
|
||||
|
||||
it('sets empty state when user has no couple', async () => {
|
||||
useAuthStore.setState({ user: { id: 'me', couple_id: null } } as any);
|
||||
|
||||
await useMatchStore.getState().fetchMatches(true);
|
||||
|
||||
const state = useMatchStore.getState();
|
||||
expect(state.matches).toEqual([]);
|
||||
expect(state.newMatchesCount).toBe(0);
|
||||
expect(state.totalCount).toBe(0);
|
||||
expect(supabase.from).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prevents concurrent loading', async () => {
|
||||
useMatchStore.setState({ isLoading: true });
|
||||
|
||||
await useMatchStore.getState().fetchMatches(true);
|
||||
|
||||
expect(supabase.from).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('filters out archived matches', async () => {
|
||||
const matches = [
|
||||
{ id: 'a', question_id: 'q1', created_at: '2024-01-02T00:00:00.000Z', is_new: false },
|
||||
{ id: 'b', question_id: 'q2', created_at: '2024-01-03T00:00:00.000Z', is_new: false },
|
||||
];
|
||||
|
||||
const archivesQuery = createThenableQuery({ data: [{ match_id: 'a' }] });
|
||||
const countQuery = createThenableQuery({ count: 2 });
|
||||
const matchesQuery = createThenableQuery({ data: matches });
|
||||
const responsesQuery = createThenableQuery({ data: [] });
|
||||
const unreadQuery = createThenableQuery({ data: [], error: null });
|
||||
|
||||
(supabase.from as jest.Mock)
|
||||
.mockReturnValueOnce(archivesQuery)
|
||||
.mockReturnValueOnce(countQuery)
|
||||
.mockReturnValueOnce(matchesQuery)
|
||||
.mockReturnValueOnce(responsesQuery)
|
||||
.mockReturnValueOnce(unreadQuery);
|
||||
|
||||
await useMatchStore.getState().fetchMatches(true);
|
||||
|
||||
const state = useMatchStore.getState();
|
||||
expect(state.matches).toHaveLength(1);
|
||||
expect(state.matches[0].id).toBe('b');
|
||||
// Total count should subtract archived
|
||||
expect(state.totalCount).toBe(1);
|
||||
});
|
||||
|
||||
it('handles empty matches correctly', async () => {
|
||||
const archivesQuery = createThenableQuery({ data: [] });
|
||||
const countQuery = createThenableQuery({ count: 0 });
|
||||
const matchesQuery = createThenableQuery({ data: [] });
|
||||
|
||||
(supabase.from as jest.Mock)
|
||||
.mockReturnValueOnce(archivesQuery)
|
||||
.mockReturnValueOnce(countQuery)
|
||||
.mockReturnValueOnce(matchesQuery);
|
||||
|
||||
await useMatchStore.getState().fetchMatches(true);
|
||||
|
||||
const state = useMatchStore.getState();
|
||||
expect(state.matches).toEqual([]);
|
||||
expect(state.hasMore).toBe(false);
|
||||
expect(state.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('handles fetch error gracefully', async () => {
|
||||
const archivesQuery = createThenableQuery({ data: [] });
|
||||
const countQuery = createThenableQuery({ count: 0 });
|
||||
// Use createThenableQuery with error data to simulate error response
|
||||
const matchesQuery = createThenableQuery({ data: null, error: { message: 'Network error' } });
|
||||
|
||||
(supabase.from as jest.Mock)
|
||||
.mockReturnValueOnce(archivesQuery)
|
||||
.mockReturnValueOnce(countQuery)
|
||||
.mockReturnValueOnce(matchesQuery);
|
||||
|
||||
await useMatchStore.getState().fetchMatches(true);
|
||||
|
||||
const state = useMatchStore.getState();
|
||||
expect(state.error).toBe('Failed to load matches');
|
||||
expect(state.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('paginates matches correctly', async () => {
|
||||
useMatchStore.setState({
|
||||
matches: [{ id: 'existing' }] as any,
|
||||
page: 1,
|
||||
hasMore: true,
|
||||
});
|
||||
|
||||
const newMatches = [
|
||||
{ id: 'c', question_id: 'q3', created_at: '2024-01-01T00:00:00.000Z', is_new: false },
|
||||
];
|
||||
|
||||
const matchesQuery = createThenableQuery({ data: newMatches });
|
||||
const responsesQuery = createThenableQuery({ data: [] });
|
||||
const unreadQuery = createThenableQuery({ data: [] });
|
||||
|
||||
(supabase.from as jest.Mock)
|
||||
.mockReturnValueOnce(matchesQuery)
|
||||
.mockReturnValueOnce(responsesQuery)
|
||||
.mockReturnValueOnce(unreadQuery);
|
||||
|
||||
await useMatchStore.getState().fetchMatches(false);
|
||||
|
||||
const state = useMatchStore.getState();
|
||||
expect(state.matches).toHaveLength(2);
|
||||
expect(state.page).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markAsSeen', () => {
|
||||
it('marks a match as seen and updates count', async () => {
|
||||
useMatchStore.setState({
|
||||
matches: [
|
||||
{ id: 'm1', is_new: true } as any,
|
||||
{ id: 'm2', is_new: true } as any,
|
||||
],
|
||||
newMatchesCount: 2,
|
||||
});
|
||||
|
||||
const updateQuery = createThenableQuery({});
|
||||
(supabase.from as jest.Mock).mockReturnValue(updateQuery);
|
||||
|
||||
await useMatchStore.getState().markAsSeen('m1');
|
||||
|
||||
const state = useMatchStore.getState();
|
||||
expect(state.matches[0].is_new).toBe(false);
|
||||
expect(state.matches[1].is_new).toBe(true);
|
||||
expect(state.newMatchesCount).toBe(1);
|
||||
expect(updateQuery.update).toHaveBeenCalledWith({ is_new: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('markAllAsSeen', () => {
|
||||
it('marks all matches as seen', async () => {
|
||||
useMatchStore.setState({
|
||||
matches: [
|
||||
{ id: 'm1', is_new: true } as any,
|
||||
{ id: 'm2', is_new: true } as any,
|
||||
],
|
||||
newMatchesCount: 2,
|
||||
});
|
||||
|
||||
const updateQuery = createThenableQuery({});
|
||||
(supabase.from as jest.Mock).mockReturnValue(updateQuery);
|
||||
|
||||
await useMatchStore.getState().markAllAsSeen();
|
||||
|
||||
const state = useMatchStore.getState();
|
||||
expect(state.matches.every(m => !m.is_new)).toBe(true);
|
||||
expect(state.newMatchesCount).toBe(0);
|
||||
});
|
||||
|
||||
it('does nothing when no new matches', async () => {
|
||||
useMatchStore.setState({
|
||||
matches: [{ id: 'm1', is_new: false } as any],
|
||||
newMatchesCount: 0,
|
||||
});
|
||||
|
||||
await useMatchStore.getState().markAllAsSeen();
|
||||
|
||||
expect(supabase.from).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('addMatch', () => {
|
||||
it('adds a new match to the beginning', () => {
|
||||
useMatchStore.setState({
|
||||
matches: [{ id: 'm1' } as any],
|
||||
newMatchesCount: 0,
|
||||
totalCount: 1,
|
||||
});
|
||||
|
||||
useMatchStore.getState().addMatch({ id: 'm2', is_new: true } as any);
|
||||
|
||||
const state = useMatchStore.getState();
|
||||
expect(state.matches[0].id).toBe('m2');
|
||||
expect(state.matches).toHaveLength(2);
|
||||
expect(state.newMatchesCount).toBe(1);
|
||||
expect(state.totalCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateMatchUnreadCount', () => {
|
||||
it('increments unread count', () => {
|
||||
useMatchStore.setState({
|
||||
matches: [{ id: 'm1', unreadCount: 1 } as any],
|
||||
});
|
||||
|
||||
useMatchStore.getState().updateMatchUnreadCount('m1', 2);
|
||||
|
||||
expect(useMatchStore.getState().matches[0].unreadCount).toBe(3);
|
||||
});
|
||||
|
||||
it('decrements unread count but not below zero', () => {
|
||||
useMatchStore.setState({
|
||||
matches: [{ id: 'm1', unreadCount: 1 } as any],
|
||||
});
|
||||
|
||||
useMatchStore.getState().updateMatchUnreadCount('m1', -5);
|
||||
|
||||
expect(useMatchStore.getState().matches[0].unreadCount).toBe(0);
|
||||
});
|
||||
|
||||
it('handles missing unreadCount', () => {
|
||||
useMatchStore.setState({
|
||||
matches: [{ id: 'm1' } as any],
|
||||
});
|
||||
|
||||
useMatchStore.getState().updateMatchUnreadCount('m1', 1);
|
||||
|
||||
expect(useMatchStore.getState().matches[0].unreadCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearMatches', () => {
|
||||
it('resets all state to initial values', () => {
|
||||
useMatchStore.setState({
|
||||
matches: [{ id: 'm1' }] as any,
|
||||
newMatchesCount: 5,
|
||||
totalCount: 10,
|
||||
isLoading: true,
|
||||
error: 'some error',
|
||||
page: 3,
|
||||
hasMore: false,
|
||||
isLoadingMore: true,
|
||||
archivedMatches: [{ id: 'a1' }] as any,
|
||||
archivedMatchIds: new Set(['a1']),
|
||||
showArchived: true,
|
||||
isLoadingArchived: true,
|
||||
});
|
||||
|
||||
useMatchStore.getState().clearMatches();
|
||||
|
||||
const state = useMatchStore.getState();
|
||||
expect(state.matches).toEqual([]);
|
||||
expect(state.newMatchesCount).toBe(0);
|
||||
expect(state.totalCount).toBeNull();
|
||||
expect(state.isLoading).toBe(false);
|
||||
expect(state.error).toBeNull();
|
||||
expect(state.page).toBe(0);
|
||||
expect(state.hasMore).toBe(true);
|
||||
expect(state.isLoadingMore).toBe(false);
|
||||
expect(state.archivedMatches).toEqual([]);
|
||||
expect(state.archivedMatchIds.size).toBe(0);
|
||||
expect(state.showArchived).toBe(false);
|
||||
expect(state.isLoadingArchived).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('archiveMatch', () => {
|
||||
it('archives a match with optimistic update', async () => {
|
||||
useMatchStore.setState({
|
||||
matches: [
|
||||
{ id: 'm1' } as any,
|
||||
{ id: 'm2' } as any,
|
||||
],
|
||||
archivedMatches: [],
|
||||
archivedMatchIds: new Set(),
|
||||
totalCount: 2,
|
||||
});
|
||||
|
||||
const insertQuery = createThenableQuery({ error: null });
|
||||
(supabase.from as jest.Mock).mockReturnValue(insertQuery);
|
||||
|
||||
await useMatchStore.getState().archiveMatch('m1');
|
||||
|
||||
const state = useMatchStore.getState();
|
||||
expect(state.matches).toHaveLength(1);
|
||||
expect(state.matches[0].id).toBe('m2');
|
||||
expect(state.archivedMatches).toHaveLength(1);
|
||||
expect(state.archivedMatches[0].id).toBe('m1');
|
||||
expect(state.archivedMatchIds.has('m1')).toBe(true);
|
||||
expect(state.totalCount).toBe(1);
|
||||
});
|
||||
|
||||
it('does nothing if user not authenticated', async () => {
|
||||
useAuthStore.setState({ user: null } as any);
|
||||
|
||||
await useMatchStore.getState().archiveMatch('m1');
|
||||
|
||||
expect(supabase.from).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reverts on error', async () => {
|
||||
useMatchStore.setState({
|
||||
matches: [{ id: 'm1' } as any],
|
||||
archivedMatches: [],
|
||||
archivedMatchIds: new Set(),
|
||||
totalCount: 1,
|
||||
});
|
||||
|
||||
const insertQuery = createThenableQuery({ error: new Error('DB error') });
|
||||
(supabase.from as jest.Mock).mockReturnValue(insertQuery);
|
||||
|
||||
await useMatchStore.getState().archiveMatch('m1');
|
||||
|
||||
const state = useMatchStore.getState();
|
||||
// Should revert
|
||||
expect(state.matches).toHaveLength(1);
|
||||
expect(state.archivedMatchIds.has('m1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unarchiveMatch', () => {
|
||||
it('unarchives a match with optimistic update', async () => {
|
||||
useMatchStore.setState({
|
||||
matches: [],
|
||||
archivedMatches: [{ id: 'm1' } as any],
|
||||
archivedMatchIds: new Set(['m1']),
|
||||
totalCount: 0,
|
||||
});
|
||||
|
||||
const deleteQuery = createThenableQuery({ error: null });
|
||||
(supabase.from as jest.Mock).mockReturnValue(deleteQuery);
|
||||
|
||||
await useMatchStore.getState().unarchiveMatch('m1');
|
||||
|
||||
const state = useMatchStore.getState();
|
||||
expect(state.matches).toHaveLength(1);
|
||||
expect(state.matches[0].id).toBe('m1');
|
||||
expect(state.archivedMatches).toHaveLength(0);
|
||||
expect(state.archivedMatchIds.has('m1')).toBe(false);
|
||||
expect(state.totalCount).toBe(1);
|
||||
});
|
||||
|
||||
it('does nothing if user not authenticated', async () => {
|
||||
useAuthStore.setState({ user: null } as any);
|
||||
|
||||
await useMatchStore.getState().unarchiveMatch('m1');
|
||||
|
||||
expect(supabase.from).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchArchivedMatches', () => {
|
||||
it('fetches archived matches', async () => {
|
||||
const archivesQuery = createThenableQuery({ data: [{ match_id: 'm1' }] });
|
||||
const matchesQuery = createThenableQuery({
|
||||
data: [{ id: 'm1', question_id: 'q1', created_at: '2024-01-01' }],
|
||||
});
|
||||
const unreadQuery = createThenableQuery({ data: [] });
|
||||
|
||||
(supabase.from as jest.Mock)
|
||||
.mockReturnValueOnce(archivesQuery)
|
||||
.mockReturnValueOnce(matchesQuery)
|
||||
.mockReturnValueOnce(unreadQuery);
|
||||
|
||||
await useMatchStore.getState().fetchArchivedMatches();
|
||||
|
||||
const state = useMatchStore.getState();
|
||||
expect(state.archivedMatches).toHaveLength(1);
|
||||
expect(state.archivedMatches[0].id).toBe('m1');
|
||||
expect(state.isLoadingArchived).toBe(false);
|
||||
});
|
||||
|
||||
it('does nothing if no couple', async () => {
|
||||
useAuthStore.setState({ user: { id: 'me', couple_id: null } } as any);
|
||||
|
||||
await useMatchStore.getState().fetchArchivedMatches();
|
||||
|
||||
expect(supabase.from).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles empty archives', async () => {
|
||||
const archivesQuery = createThenableQuery({ data: [] });
|
||||
|
||||
(supabase.from as jest.Mock).mockReturnValue(archivesQuery);
|
||||
|
||||
await useMatchStore.getState().fetchArchivedMatches();
|
||||
|
||||
const state = useMatchStore.getState();
|
||||
expect(state.archivedMatches).toEqual([]);
|
||||
expect(state.isLoadingArchived).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleShowArchived', () => {
|
||||
it('toggles showArchived state', () => {
|
||||
expect(useMatchStore.getState().showArchived).toBe(false);
|
||||
|
||||
useMatchStore.getState().toggleShowArchived();
|
||||
|
||||
expect(useMatchStore.getState().showArchived).toBe(true);
|
||||
|
||||
useMatchStore.getState().toggleShowArchived();
|
||||
|
||||
expect(useMatchStore.getState().showArchived).toBe(false);
|
||||
});
|
||||
|
||||
it('fetches archived matches when toggling to show', async () => {
|
||||
const archivesQuery = createThenableQuery({ data: [] });
|
||||
(supabase.from as jest.Mock).mockReturnValue(archivesQuery);
|
||||
|
||||
useMatchStore.getState().toggleShowArchived();
|
||||
|
||||
// Should trigger fetch
|
||||
expect(supabase.from).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMatchArchived', () => {
|
||||
it('returns true for archived matches', () => {
|
||||
useMatchStore.setState({
|
||||
archivedMatchIds: new Set(['m1', 'm2']),
|
||||
});
|
||||
|
||||
expect(useMatchStore.getState().isMatchArchived('m1')).toBe(true);
|
||||
expect(useMatchStore.getState().isMatchArchived('m2')).toBe(true);
|
||||
expect(useMatchStore.getState().isMatchArchived('m3')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,499 @@
|
||||
import { useResponsesStore, groupResponses, ResponseWithQuestion } from '@/store/responsesStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useMatchStore } from '@/store/matchStore';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
|
||||
// Mock authErrorHandler
|
||||
jest.mock('@/lib/authErrorHandler', () => ({
|
||||
invokeWithAuthRetry: jest.fn(),
|
||||
}));
|
||||
|
||||
import { invokeWithAuthRetry } from '@/lib/authErrorHandler';
|
||||
|
||||
function createThenableQuery(result: any) {
|
||||
const query: any = {
|
||||
select: jest.fn(() => query),
|
||||
eq: jest.fn(() => query),
|
||||
neq: jest.fn(() => query),
|
||||
is: jest.fn(() => query),
|
||||
in: jest.fn(() => query),
|
||||
order: jest.fn(() => query),
|
||||
range: jest.fn(() => query),
|
||||
then: (resolve: any, reject: any) => Promise.resolve(result).then(resolve, reject),
|
||||
};
|
||||
return query;
|
||||
}
|
||||
|
||||
describe('responsesStore', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useResponsesStore.setState({
|
||||
responses: [],
|
||||
isLoading: false,
|
||||
isLoadingMore: false,
|
||||
groupBy: 'date',
|
||||
dateSortOrder: 'newest',
|
||||
hasMore: true,
|
||||
page: 0,
|
||||
totalCount: null,
|
||||
});
|
||||
useAuthStore.setState({ user: { id: 'me', couple_id: 'couple1' } } as any);
|
||||
useMatchStore.setState({ matches: [], fetchMatches: jest.fn() } as any);
|
||||
});
|
||||
|
||||
describe('fetchResponses', () => {
|
||||
it('fetches responses with questions and packs on refresh', async () => {
|
||||
const responses = [
|
||||
{
|
||||
id: 'r1',
|
||||
question_id: 'q1',
|
||||
answer: 'yes',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
question: {
|
||||
id: 'q1',
|
||||
text: 'Question 1',
|
||||
partner_text: null,
|
||||
intensity: 2,
|
||||
pack_id: 'p1',
|
||||
pack: { id: 'p1', name: 'Pack 1', icon: '❤️' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const countQuery = createThenableQuery({ count: 1 });
|
||||
const responsesQuery = createThenableQuery({ data: responses });
|
||||
const matchesQuery = createThenableQuery({ data: [{ id: 'm1', question_id: 'q1' }] });
|
||||
const partnerQuery = createThenableQuery({ data: [{ question_id: 'q1' }] });
|
||||
|
||||
(supabase.from as jest.Mock)
|
||||
.mockReturnValueOnce(countQuery)
|
||||
.mockReturnValueOnce(responsesQuery)
|
||||
.mockReturnValueOnce(matchesQuery)
|
||||
.mockReturnValueOnce(partnerQuery);
|
||||
|
||||
await useResponsesStore.getState().fetchResponses(true);
|
||||
|
||||
const state = useResponsesStore.getState();
|
||||
expect(state.responses).toHaveLength(1);
|
||||
expect(state.responses[0].has_match).toBe(true);
|
||||
expect(state.responses[0].match_id).toBe('m1');
|
||||
expect(state.responses[0].partner_answered).toBe(true);
|
||||
expect(state.totalCount).toBe(1);
|
||||
expect(state.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('does nothing if user is not authenticated', async () => {
|
||||
useAuthStore.setState({ user: null } as any);
|
||||
|
||||
await useResponsesStore.getState().fetchResponses(true);
|
||||
|
||||
expect(supabase.from).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing if couple_id is missing', async () => {
|
||||
useAuthStore.setState({ user: { id: 'me', couple_id: null } } as any);
|
||||
|
||||
await useResponsesStore.getState().fetchResponses(true);
|
||||
|
||||
expect(supabase.from).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prevents concurrent loading', async () => {
|
||||
useResponsesStore.setState({ isLoading: true });
|
||||
|
||||
await useResponsesStore.getState().fetchResponses(true);
|
||||
|
||||
expect(supabase.from).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles empty responses correctly', async () => {
|
||||
const countQuery = createThenableQuery({ count: 0 });
|
||||
const responsesQuery = createThenableQuery({ data: [] });
|
||||
|
||||
(supabase.from as jest.Mock)
|
||||
.mockReturnValueOnce(countQuery)
|
||||
.mockReturnValueOnce(responsesQuery);
|
||||
|
||||
await useResponsesStore.getState().fetchResponses(true);
|
||||
|
||||
const state = useResponsesStore.getState();
|
||||
expect(state.responses).toEqual([]);
|
||||
expect(state.hasMore).toBe(false);
|
||||
expect(state.totalCount).toBe(0);
|
||||
});
|
||||
|
||||
it('paginates responses correctly', async () => {
|
||||
// Set up initial state with some responses
|
||||
useResponsesStore.setState({
|
||||
page: 1,
|
||||
hasMore: true,
|
||||
responses: [{ id: 'r0' }] as any,
|
||||
});
|
||||
|
||||
const responses = [
|
||||
{
|
||||
id: 'r1',
|
||||
question_id: 'q1',
|
||||
answer: 'yes',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
question: {
|
||||
id: 'q1',
|
||||
text: 'Question 1',
|
||||
pack: { id: 'p1', name: 'Pack 1', icon: '❤️' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const responsesQuery = createThenableQuery({ data: responses });
|
||||
const matchesQuery = createThenableQuery({ data: [] });
|
||||
const partnerQuery = createThenableQuery({ data: [] });
|
||||
|
||||
(supabase.from as jest.Mock)
|
||||
.mockReturnValueOnce(responsesQuery)
|
||||
.mockReturnValueOnce(matchesQuery)
|
||||
.mockReturnValueOnce(partnerQuery);
|
||||
|
||||
await useResponsesStore.getState().fetchResponses(false);
|
||||
|
||||
const state = useResponsesStore.getState();
|
||||
// Should append to existing responses
|
||||
expect(state.responses).toHaveLength(2);
|
||||
expect(state.page).toBe(2);
|
||||
});
|
||||
|
||||
it('handles fetch errors gracefully', async () => {
|
||||
const countQuery = createThenableQuery({ count: 1 });
|
||||
const responsesQuery = createThenableQuery({ data: null, error: new Error('Network error') });
|
||||
|
||||
(supabase.from as jest.Mock)
|
||||
.mockReturnValueOnce(countQuery)
|
||||
.mockReturnValueOnce(responsesQuery);
|
||||
|
||||
await useResponsesStore.getState().fetchResponses(true);
|
||||
|
||||
const state = useResponsesStore.getState();
|
||||
expect(state.isLoading).toBe(false);
|
||||
expect(state.isLoadingMore).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateResponse', () => {
|
||||
it('updates response and refreshes matches on success', async () => {
|
||||
const fetchMatchesMock = jest.fn();
|
||||
useMatchStore.setState({ fetchMatches: fetchMatchesMock } as any);
|
||||
useResponsesStore.setState({
|
||||
responses: [
|
||||
{ question_id: 'q1', answer: 'maybe', has_match: false } as any,
|
||||
],
|
||||
});
|
||||
|
||||
(invokeWithAuthRetry as jest.Mock).mockResolvedValueOnce({
|
||||
data: { success: true, new_match: { id: 'm1' } },
|
||||
error: null,
|
||||
});
|
||||
|
||||
const result = await useResponsesStore.getState().updateResponse('q1', 'yes');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.new_match).toEqual({ id: 'm1' });
|
||||
|
||||
const state = useResponsesStore.getState();
|
||||
expect(state.responses[0].answer).toBe('yes');
|
||||
expect(state.responses[0].has_match).toBe(true);
|
||||
expect(state.responses[0].match_id).toBe('m1');
|
||||
expect(fetchMatchesMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles match deletion correctly', async () => {
|
||||
const fetchMatchesMock = jest.fn();
|
||||
useMatchStore.setState({ fetchMatches: fetchMatchesMock } as any);
|
||||
useResponsesStore.setState({
|
||||
responses: [
|
||||
{ question_id: 'q1', answer: 'yes', has_match: true, match_id: 'm1' } as any,
|
||||
],
|
||||
});
|
||||
|
||||
(invokeWithAuthRetry as jest.Mock).mockResolvedValueOnce({
|
||||
data: { success: true, match_deleted: true },
|
||||
error: null,
|
||||
});
|
||||
|
||||
const result = await useResponsesStore.getState().updateResponse('q1', 'no', true);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.match_deleted).toBe(true);
|
||||
|
||||
const state = useResponsesStore.getState();
|
||||
expect(state.responses[0].answer).toBe('no');
|
||||
expect(state.responses[0].has_match).toBe(false);
|
||||
expect(state.responses[0].match_id).toBeUndefined();
|
||||
expect(fetchMatchesMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns requires_confirmation without updating local state', async () => {
|
||||
useResponsesStore.setState({
|
||||
responses: [
|
||||
{ question_id: 'q1', answer: 'yes', has_match: true } as any,
|
||||
],
|
||||
});
|
||||
|
||||
(invokeWithAuthRetry as jest.Mock).mockResolvedValueOnce({
|
||||
data: { success: false, requires_confirmation: true, message_count: 5 },
|
||||
error: null,
|
||||
});
|
||||
|
||||
const result = await useResponsesStore.getState().updateResponse('q1', 'no');
|
||||
|
||||
expect(result.requires_confirmation).toBe(true);
|
||||
expect(result.message_count).toBe(5);
|
||||
|
||||
const state = useResponsesStore.getState();
|
||||
// Should not update local state
|
||||
expect(state.responses[0].answer).toBe('yes');
|
||||
});
|
||||
|
||||
it('handles errors from edge function', async () => {
|
||||
(invokeWithAuthRetry as jest.Mock).mockResolvedValueOnce({
|
||||
data: null,
|
||||
error: { message: 'Server error' },
|
||||
});
|
||||
|
||||
const result = await useResponsesStore.getState().updateResponse('q1', 'yes');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Server error');
|
||||
});
|
||||
|
||||
it('refreshes matches when match_type_updated', async () => {
|
||||
const fetchMatchesMock = jest.fn();
|
||||
useMatchStore.setState({ fetchMatches: fetchMatchesMock } as any);
|
||||
useResponsesStore.setState({
|
||||
responses: [
|
||||
{ question_id: 'q1', answer: 'maybe', has_match: true, match_id: 'm1' } as any,
|
||||
],
|
||||
});
|
||||
|
||||
(invokeWithAuthRetry as jest.Mock).mockResolvedValueOnce({
|
||||
data: { success: true, match_type_updated: true },
|
||||
error: null,
|
||||
});
|
||||
|
||||
await useResponsesStore.getState().updateResponse('q1', 'yes');
|
||||
|
||||
expect(fetchMatchesMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setGroupBy', () => {
|
||||
it('updates groupBy option', () => {
|
||||
useResponsesStore.getState().setGroupBy('pack');
|
||||
expect(useResponsesStore.getState().groupBy).toBe('pack');
|
||||
|
||||
useResponsesStore.getState().setGroupBy('answer');
|
||||
expect(useResponsesStore.getState().groupBy).toBe('answer');
|
||||
|
||||
useResponsesStore.getState().setGroupBy('date');
|
||||
expect(useResponsesStore.getState().groupBy).toBe('date');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setDateSortOrder', () => {
|
||||
it('updates date sort order', () => {
|
||||
useResponsesStore.getState().setDateSortOrder('oldest');
|
||||
expect(useResponsesStore.getState().dateSortOrder).toBe('oldest');
|
||||
|
||||
useResponsesStore.getState().setDateSortOrder('newest');
|
||||
expect(useResponsesStore.getState().dateSortOrder).toBe('newest');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleDateSortOrder', () => {
|
||||
it('toggles between newest and oldest', () => {
|
||||
expect(useResponsesStore.getState().dateSortOrder).toBe('newest');
|
||||
|
||||
useResponsesStore.getState().toggleDateSortOrder();
|
||||
expect(useResponsesStore.getState().dateSortOrder).toBe('oldest');
|
||||
|
||||
useResponsesStore.getState().toggleDateSortOrder();
|
||||
expect(useResponsesStore.getState().dateSortOrder).toBe('newest');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearResponses', () => {
|
||||
it('resets store to initial state', () => {
|
||||
useResponsesStore.setState({
|
||||
responses: [{ id: 'r1' } as any],
|
||||
isLoading: true,
|
||||
groupBy: 'pack',
|
||||
dateSortOrder: 'oldest',
|
||||
page: 5,
|
||||
hasMore: false,
|
||||
isLoadingMore: true,
|
||||
totalCount: 10,
|
||||
});
|
||||
|
||||
useResponsesStore.getState().clearResponses();
|
||||
|
||||
const state = useResponsesStore.getState();
|
||||
expect(state.responses).toEqual([]);
|
||||
expect(state.isLoading).toBe(false);
|
||||
expect(state.groupBy).toBe('date');
|
||||
expect(state.dateSortOrder).toBe('newest');
|
||||
expect(state.page).toBe(0);
|
||||
expect(state.hasMore).toBe(true);
|
||||
expect(state.isLoadingMore).toBe(false);
|
||||
expect(state.totalCount).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupResponses', () => {
|
||||
const mockResponses: ResponseWithQuestion[] = [
|
||||
{
|
||||
id: 'r1',
|
||||
question_id: 'q1',
|
||||
answer: 'yes',
|
||||
created_at: '2024-01-15T10:00:00.000Z',
|
||||
question: {
|
||||
id: 'q1',
|
||||
text: 'Question 1',
|
||||
partner_text: null,
|
||||
intensity: 2,
|
||||
pack_id: 'p1',
|
||||
created_at: '',
|
||||
pack: { id: 'p1', name: 'Pack A', icon: '❤️' },
|
||||
},
|
||||
has_match: true,
|
||||
partner_answered: true,
|
||||
},
|
||||
{
|
||||
id: 'r2',
|
||||
question_id: 'q2',
|
||||
answer: 'maybe',
|
||||
created_at: '2024-01-15T14:00:00.000Z',
|
||||
question: {
|
||||
id: 'q2',
|
||||
text: 'Question 2',
|
||||
partner_text: null,
|
||||
intensity: 3,
|
||||
pack_id: 'p2',
|
||||
created_at: '',
|
||||
pack: { id: 'p2', name: 'Pack B', icon: '💜' },
|
||||
},
|
||||
has_match: false,
|
||||
partner_answered: false,
|
||||
},
|
||||
{
|
||||
id: 'r3',
|
||||
question_id: 'q3',
|
||||
answer: 'no',
|
||||
created_at: '2024-01-14T10:00:00.000Z',
|
||||
question: {
|
||||
id: 'q3',
|
||||
text: 'Question 3',
|
||||
partner_text: null,
|
||||
intensity: 1,
|
||||
pack_id: 'p1',
|
||||
created_at: '',
|
||||
pack: { id: 'p1', name: 'Pack A', icon: '❤️' },
|
||||
},
|
||||
has_match: false,
|
||||
partner_answered: true,
|
||||
},
|
||||
{
|
||||
id: 'r4',
|
||||
question_id: 'q4',
|
||||
answer: 'yes',
|
||||
created_at: '2024-01-14T14:00:00.000Z',
|
||||
question: {
|
||||
id: 'q4',
|
||||
text: 'Question 4',
|
||||
partner_text: null,
|
||||
intensity: 2,
|
||||
pack_id: 'p2',
|
||||
created_at: '',
|
||||
pack: { id: 'p2', name: 'Pack B', icon: '💜' },
|
||||
},
|
||||
has_match: true,
|
||||
partner_answered: true,
|
||||
},
|
||||
];
|
||||
|
||||
describe('groupBy pack', () => {
|
||||
it('groups responses by pack name', () => {
|
||||
const grouped = groupResponses(mockResponses, 'pack');
|
||||
|
||||
expect(grouped).toHaveLength(2);
|
||||
|
||||
const packA = grouped.find(g => g.title === 'Pack A');
|
||||
const packB = grouped.find(g => g.title === 'Pack B');
|
||||
|
||||
expect(packA?.data).toHaveLength(2);
|
||||
expect(packB?.data).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupBy answer', () => {
|
||||
it('groups responses by answer type in order: yes, maybe, no', () => {
|
||||
const grouped = groupResponses(mockResponses, 'answer');
|
||||
|
||||
expect(grouped).toHaveLength(3);
|
||||
expect(grouped[0].title).toBe('Yes');
|
||||
expect(grouped[0].data).toHaveLength(2);
|
||||
expect(grouped[1].title).toBe('Maybe');
|
||||
expect(grouped[1].data).toHaveLength(1);
|
||||
expect(grouped[2].title).toBe('No');
|
||||
expect(grouped[2].data).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('excludes empty answer groups', () => {
|
||||
const yesOnlyResponses = mockResponses.filter(r => r.answer === 'yes');
|
||||
const grouped = groupResponses(yesOnlyResponses, 'answer');
|
||||
|
||||
expect(grouped).toHaveLength(1);
|
||||
expect(grouped[0].title).toBe('Yes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupBy date', () => {
|
||||
it('groups responses by date with newest first', () => {
|
||||
const grouped = groupResponses(mockResponses, 'date', 'newest');
|
||||
|
||||
expect(grouped).toHaveLength(2);
|
||||
// January 15 should be first (newer)
|
||||
expect(grouped[0].title).toContain('January 15');
|
||||
expect(grouped[0].data).toHaveLength(2);
|
||||
expect(grouped[1].title).toContain('January 14');
|
||||
expect(grouped[1].data).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('groups responses by date with oldest first', () => {
|
||||
const grouped = groupResponses(mockResponses, 'date', 'oldest');
|
||||
|
||||
expect(grouped).toHaveLength(2);
|
||||
// January 14 should be first (older)
|
||||
expect(grouped[0].title).toContain('January 14');
|
||||
expect(grouped[1].title).toContain('January 15');
|
||||
});
|
||||
|
||||
it('sorts responses within each date group', () => {
|
||||
const grouped = groupResponses(mockResponses, 'date', 'newest');
|
||||
|
||||
// Within January 15, the response at 14:00 should come first (newest)
|
||||
const jan15Group = grouped[0];
|
||||
expect(new Date(jan15Group.data[0].created_at).getTime())
|
||||
.toBeGreaterThan(new Date(jan15Group.data[1].created_at).getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe('default grouping', () => {
|
||||
it('returns all responses in a single group for unknown groupBy', () => {
|
||||
// @ts-expect-error Testing unknown groupBy value
|
||||
const grouped = groupResponses(mockResponses, 'unknown');
|
||||
|
||||
expect(grouped).toHaveLength(1);
|
||||
expect(grouped[0].title).toBe('All Responses');
|
||||
expect(grouped[0].data).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,12 @@
|
||||
jest.mock('../../lib/revenuecat', () => {
|
||||
const service = {
|
||||
jest.mock('../../lib/revenuecat', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
isAvailable: jest.fn(() => true),
|
||||
isInitialized: jest.fn(() => true),
|
||||
initialize: jest.fn(async () => {}),
|
||||
login: jest.fn(async () => null),
|
||||
logout: jest.fn(async () => {}),
|
||||
addCustomerInfoUpdateListener: jest.fn(() => null),
|
||||
addCustomerInfoUpdateListener: jest.fn(() => () => {}),
|
||||
getOfferingsDebug: jest.fn(async () => ({
|
||||
current: { identifier: 'default', availablePackages: [{ identifier: 'monthly', packageType: 'MONTHLY', product: { price: 1, priceString: '$1', currencyCode: 'USD' } }] },
|
||||
availableOfferings: ['default'],
|
||||
@@ -15,24 +16,33 @@ jest.mock('../../lib/revenuecat', () => {
|
||||
restorePurchases: jest.fn(async () => ({ entitlements: { active: {} } })),
|
||||
getCustomerInfo: jest.fn(async () => ({ entitlements: { active: {} } })),
|
||||
parseCustomerInfo: jest.fn(() => ({ isProUser: false, activeSubscription: null, expirationDate: null, willRenew: false })),
|
||||
};
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
default: service,
|
||||
revenueCatService: service,
|
||||
SubscriptionState: {},
|
||||
PurchasesPackage: {},
|
||||
PurchasesOffering: {},
|
||||
};
|
||||
});
|
||||
},
|
||||
get revenueCatService() {
|
||||
return this.default;
|
||||
},
|
||||
SubscriptionState: {},
|
||||
PurchasesPackage: {},
|
||||
PurchasesOffering: {},
|
||||
}));
|
||||
|
||||
import { useSubscriptionStore } from '@/store/subscriptionStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import revenueCatService from '../../lib/revenuecat';
|
||||
|
||||
describe('subscriptionStore', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Reset mock implementations to defaults
|
||||
(revenueCatService.isAvailable as jest.Mock).mockReturnValue(true);
|
||||
(revenueCatService.isInitialized as jest.Mock).mockReturnValue(true);
|
||||
(revenueCatService.parseCustomerInfo as jest.Mock).mockReturnValue({
|
||||
isProUser: false,
|
||||
activeSubscription: null,
|
||||
expirationDate: null,
|
||||
willRenew: false,
|
||||
});
|
||||
|
||||
useSubscriptionStore.setState({
|
||||
subscription: { isProUser: false, activeSubscription: null, expirationDate: null, willRenew: false },
|
||||
offerings: null,
|
||||
@@ -40,24 +50,236 @@ describe('subscriptionStore', () => {
|
||||
isPurchasing: false,
|
||||
error: null,
|
||||
} as any);
|
||||
useAuthStore.setState({ user: { id: 'me', is_premium: false } } as any);
|
||||
useAuthStore.setState({
|
||||
user: { id: 'me', is_premium: false },
|
||||
fetchUser: jest.fn(),
|
||||
} as any);
|
||||
});
|
||||
|
||||
it('fetchOfferings stores offering on success', async () => {
|
||||
await useSubscriptionStore.getState().fetchOfferings();
|
||||
describe('initializeRevenueCat', () => {
|
||||
it('skips initialization if RevenueCat not available', async () => {
|
||||
(revenueCatService.isAvailable as jest.Mock).mockReturnValue(false);
|
||||
|
||||
const state = useSubscriptionStore.getState();
|
||||
expect(state.offerings).toBeTruthy();
|
||||
expect(state.isLoadingOfferings).toBe(false);
|
||||
expect(state.error).toBeNull();
|
||||
await useSubscriptionStore.getState().initializeRevenueCat('user1');
|
||||
|
||||
expect(revenueCatService.initialize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('initializes and logs in when available', async () => {
|
||||
await useSubscriptionStore.getState().initializeRevenueCat('user1');
|
||||
|
||||
expect(revenueCatService.initialize).toHaveBeenCalledWith('user1');
|
||||
expect(revenueCatService.login).toHaveBeenCalledWith('user1');
|
||||
expect(revenueCatService.addCustomerInfoUpdateListener).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles initialization errors gracefully', async () => {
|
||||
(revenueCatService.initialize as jest.Mock).mockRejectedValueOnce(new Error('Init failed'));
|
||||
|
||||
// Should not throw
|
||||
await useSubscriptionStore.getState().initializeRevenueCat('user1');
|
||||
|
||||
// Error is logged but not set in state
|
||||
expect(useSubscriptionStore.getState().error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('clearSubscription resets state', () => {
|
||||
useSubscriptionStore.setState({ offerings: { identifier: 'x', availablePackages: [] } } as any);
|
||||
useSubscriptionStore.getState().clearSubscription();
|
||||
describe('fetchOfferings', () => {
|
||||
it('stores offering on success', async () => {
|
||||
await useSubscriptionStore.getState().fetchOfferings();
|
||||
|
||||
const state = useSubscriptionStore.getState();
|
||||
expect(state.offerings).toBeNull();
|
||||
expect(state.subscription.isProUser).toBe(false);
|
||||
const state = useSubscriptionStore.getState();
|
||||
expect(state.offerings).toBeTruthy();
|
||||
expect(state.isLoadingOfferings).toBe(false);
|
||||
expect(state.error).toBeNull();
|
||||
});
|
||||
|
||||
it('sets error if RevenueCat not initialized', async () => {
|
||||
(revenueCatService.isInitialized as jest.Mock).mockReturnValue(false);
|
||||
|
||||
await useSubscriptionStore.getState().fetchOfferings();
|
||||
|
||||
const state = useSubscriptionStore.getState();
|
||||
expect(state.error).toBe('Subscriptions not available');
|
||||
expect(state.offerings).toBeNull();
|
||||
expect(state.isLoadingOfferings).toBe(false);
|
||||
});
|
||||
|
||||
it('handles offering fetch error', async () => {
|
||||
(revenueCatService.getOfferingsDebug as jest.Mock).mockResolvedValueOnce({
|
||||
error: 'Failed to fetch',
|
||||
});
|
||||
|
||||
await useSubscriptionStore.getState().fetchOfferings();
|
||||
|
||||
const state = useSubscriptionStore.getState();
|
||||
expect(state.error).toBe('Failed to fetch');
|
||||
expect(state.offerings).toBeNull();
|
||||
});
|
||||
|
||||
it('handles no current offering', async () => {
|
||||
(revenueCatService.getOfferingsDebug as jest.Mock).mockResolvedValueOnce({
|
||||
current: null,
|
||||
availableOfferings: ['test'],
|
||||
});
|
||||
|
||||
await useSubscriptionStore.getState().fetchOfferings();
|
||||
|
||||
const state = useSubscriptionStore.getState();
|
||||
expect(state.error).toContain('No current offering');
|
||||
});
|
||||
|
||||
it('handles empty packages', async () => {
|
||||
(revenueCatService.getOfferingsDebug as jest.Mock).mockResolvedValueOnce({
|
||||
current: { identifier: 'default', availablePackages: [] },
|
||||
});
|
||||
|
||||
await useSubscriptionStore.getState().fetchOfferings();
|
||||
|
||||
const state = useSubscriptionStore.getState();
|
||||
expect(state.error).toContain('No packages in offering');
|
||||
});
|
||||
|
||||
it('handles exception during fetch', async () => {
|
||||
(revenueCatService.getOfferingsDebug as jest.Mock).mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
await useSubscriptionStore.getState().fetchOfferings();
|
||||
|
||||
const state = useSubscriptionStore.getState();
|
||||
expect(state.error).toContain('Error: Network error');
|
||||
expect(state.isLoadingOfferings).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('purchasePackage', () => {
|
||||
it('purchases package successfully', async () => {
|
||||
const mockPackage = { identifier: 'monthly' };
|
||||
(revenueCatService.parseCustomerInfo as jest.Mock).mockReturnValue({
|
||||
isProUser: true,
|
||||
activeSubscription: 'monthly',
|
||||
expirationDate: '2025-01-01',
|
||||
willRenew: true,
|
||||
});
|
||||
|
||||
const result = await useSubscriptionStore.getState().purchasePackage(mockPackage as any);
|
||||
|
||||
expect(result).toBe(true);
|
||||
const state = useSubscriptionStore.getState();
|
||||
expect(state.subscription.isProUser).toBe(true);
|
||||
expect(state.isPurchasing).toBe(false);
|
||||
expect(useAuthStore.getState().fetchUser).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles cancelled purchase', async () => {
|
||||
const mockPackage = { identifier: 'monthly' };
|
||||
(revenueCatService.purchasePackage as jest.Mock).mockRejectedValueOnce(
|
||||
new Error('Purchase cancelled')
|
||||
);
|
||||
|
||||
const result = await useSubscriptionStore.getState().purchasePackage(mockPackage as any);
|
||||
|
||||
expect(result).toBe(false);
|
||||
const state = useSubscriptionStore.getState();
|
||||
expect(state.error).toBe('Purchase was cancelled');
|
||||
expect(state.isPurchasing).toBe(false);
|
||||
});
|
||||
|
||||
it('handles other purchase errors', async () => {
|
||||
const mockPackage = { identifier: 'monthly' };
|
||||
(revenueCatService.purchasePackage as jest.Mock).mockRejectedValueOnce(
|
||||
new Error('Payment failed')
|
||||
);
|
||||
|
||||
const result = await useSubscriptionStore.getState().purchasePackage(mockPackage as any);
|
||||
|
||||
expect(result).toBe(false);
|
||||
const state = useSubscriptionStore.getState();
|
||||
expect(state.error).toBe('Purchase failed. Please try again.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('restorePurchases', () => {
|
||||
it('restores purchases successfully when user has pro', async () => {
|
||||
(revenueCatService.parseCustomerInfo as jest.Mock).mockReturnValue({
|
||||
isProUser: true,
|
||||
activeSubscription: 'yearly',
|
||||
expirationDate: '2025-12-01',
|
||||
willRenew: true,
|
||||
});
|
||||
|
||||
const result = await useSubscriptionStore.getState().restorePurchases();
|
||||
|
||||
expect(result).toBe(true);
|
||||
const state = useSubscriptionStore.getState();
|
||||
expect(state.subscription.isProUser).toBe(true);
|
||||
expect(state.isPurchasing).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when no purchases to restore', async () => {
|
||||
const result = await useSubscriptionStore.getState().restorePurchases();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('handles restore error', async () => {
|
||||
(revenueCatService.restorePurchases as jest.Mock).mockRejectedValueOnce(
|
||||
new Error('Restore failed')
|
||||
);
|
||||
|
||||
const result = await useSubscriptionStore.getState().restorePurchases();
|
||||
|
||||
expect(result).toBe(false);
|
||||
const state = useSubscriptionStore.getState();
|
||||
expect(state.error).toBe('Failed to restore purchases');
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshSubscriptionStatus', () => {
|
||||
it('refreshes subscription status', async () => {
|
||||
(revenueCatService.parseCustomerInfo as jest.Mock).mockReturnValue({
|
||||
isProUser: true,
|
||||
activeSubscription: 'monthly',
|
||||
expirationDate: '2025-01-01',
|
||||
willRenew: true,
|
||||
});
|
||||
|
||||
await useSubscriptionStore.getState().refreshSubscriptionStatus();
|
||||
|
||||
const state = useSubscriptionStore.getState();
|
||||
expect(state.subscription.isProUser).toBe(true);
|
||||
});
|
||||
|
||||
it('handles refresh errors gracefully', async () => {
|
||||
(revenueCatService.getCustomerInfo as jest.Mock).mockRejectedValueOnce(
|
||||
new Error('Network error')
|
||||
);
|
||||
|
||||
// Should not throw
|
||||
await useSubscriptionStore.getState().refreshSubscriptionStatus();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearSubscription', () => {
|
||||
it('resets state and logs out', () => {
|
||||
useSubscriptionStore.setState({
|
||||
subscription: {
|
||||
isProUser: true,
|
||||
activeSubscription: 'monthly',
|
||||
expirationDate: new Date('2025-01-01'),
|
||||
willRenew: true,
|
||||
},
|
||||
offerings: { identifier: 'x', availablePackages: [] } as any,
|
||||
error: 'some error',
|
||||
});
|
||||
|
||||
useSubscriptionStore.getState().clearSubscription();
|
||||
|
||||
const state = useSubscriptionStore.getState();
|
||||
expect(state.offerings).toBeNull();
|
||||
expect(state.subscription.isProUser).toBe(false);
|
||||
expect(state.subscription.activeSubscription).toBeNull();
|
||||
expect(state.error).toBeNull();
|
||||
expect(revenueCatService.logout).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,526 @@
|
||||
/**
|
||||
* MatchNotificationModal - Premium celebration modal for new matches
|
||||
*
|
||||
* Features:
|
||||
* - Glass-morphism styling following boutique design aesthetic
|
||||
* - Animated floating hearts celebration effect
|
||||
* - Shows what each partner answered (You / Partner)
|
||||
* - Uses brand gradients and premium styling
|
||||
*/
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
Animated,
|
||||
} from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { BlurView } from 'expo-blur';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { colors, gradients, spacing, radius, typography, shadows } from '../theme';
|
||||
import { DecorativeSeparator } from './ui';
|
||||
import type { MatchType, AnswerType } from '../types';
|
||||
|
||||
export interface MatchNotificationData {
|
||||
id: string;
|
||||
match_type: MatchType;
|
||||
question: {
|
||||
id: string;
|
||||
text: string;
|
||||
partner_text?: string | null;
|
||||
};
|
||||
userResponse?: AnswerType;
|
||||
partnerResponse?: AnswerType;
|
||||
partnerName?: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
match: MatchNotificationData | null;
|
||||
onDismiss: () => void;
|
||||
onChat: () => void;
|
||||
}
|
||||
|
||||
// Helper to get response styling
|
||||
const getResponseStyle = (response: AnswerType, isPartner: boolean = false) => {
|
||||
switch (response) {
|
||||
case 'yes':
|
||||
return {
|
||||
bg: isPartner ? 'rgba(46, 204, 113, 0.15)' : 'rgba(233, 69, 96, 0.15)',
|
||||
border: isPartner ? 'rgba(46, 204, 113, 0.3)' : 'rgba(233, 69, 96, 0.3)',
|
||||
color: isPartner ? colors.success : colors.primary,
|
||||
text: 'YES',
|
||||
icon: 'heart' as const,
|
||||
};
|
||||
case 'maybe':
|
||||
return {
|
||||
bg: 'rgba(243, 156, 18, 0.15)',
|
||||
border: 'rgba(243, 156, 18, 0.3)',
|
||||
color: colors.warning,
|
||||
text: 'MAYBE',
|
||||
icon: 'help-circle' as const,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
bg: 'rgba(255, 255, 255, 0.1)',
|
||||
border: 'rgba(255, 255, 255, 0.2)',
|
||||
color: colors.textSecondary,
|
||||
text: response.toUpperCase(),
|
||||
icon: 'ellipse' as const,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Compact response badge (for two-part questions)
|
||||
const ResponseBadge: React.FC<{
|
||||
response: AnswerType;
|
||||
isPartner?: boolean;
|
||||
}> = ({ response, isPartner = false }) => {
|
||||
const style = getResponseStyle(response, isPartner);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.responseBadgeCompact,
|
||||
{ backgroundColor: style.bg, borderColor: style.border },
|
||||
]}
|
||||
>
|
||||
<Ionicons name={style.icon} size={14} color={style.color} />
|
||||
<Text style={[styles.responseBadgeTextCompact, { color: style.color }]}>
|
||||
{style.text}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export const MatchNotificationModal: React.FC<Props> = ({
|
||||
visible,
|
||||
match,
|
||||
onDismiss,
|
||||
onChat,
|
||||
}) => {
|
||||
const modalScale = useRef(new Animated.Value(0.8)).current;
|
||||
const modalOpacity = useRef(new Animated.Value(0)).current;
|
||||
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
// Reset animations
|
||||
modalScale.setValue(0.8);
|
||||
modalOpacity.setValue(0);
|
||||
overlayOpacity.setValue(0);
|
||||
|
||||
// Animate in
|
||||
Animated.parallel([
|
||||
Animated.timing(overlayOpacity, {
|
||||
toValue: 1,
|
||||
duration: 250,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.spring(modalScale, {
|
||||
toValue: 1,
|
||||
tension: 80,
|
||||
friction: 10,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(modalOpacity, {
|
||||
toValue: 1,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const handleDismiss = () => {
|
||||
Animated.parallel([
|
||||
Animated.timing(overlayOpacity, {
|
||||
toValue: 0,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(modalScale, {
|
||||
toValue: 0.8,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(modalOpacity, {
|
||||
toValue: 0,
|
||||
duration: 150,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start(() => onDismiss());
|
||||
};
|
||||
|
||||
const handleChat = () => {
|
||||
Animated.parallel([
|
||||
Animated.timing(overlayOpacity, {
|
||||
toValue: 0,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(modalScale, {
|
||||
toValue: 0.8,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(modalOpacity, {
|
||||
toValue: 0,
|
||||
duration: 150,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start(() => onChat());
|
||||
};
|
||||
|
||||
if (!visible || !match) return null;
|
||||
|
||||
// Determine responses based on match_type if not explicitly provided
|
||||
const getUserResponse = (): AnswerType => {
|
||||
if (match.userResponse) return match.userResponse;
|
||||
// Infer from match_type (user typically triggers the match)
|
||||
if (match.match_type === 'yes_yes') return 'yes';
|
||||
if (match.match_type === 'maybe_maybe') return 'maybe';
|
||||
// yes_maybe - user could be either
|
||||
return 'yes';
|
||||
};
|
||||
|
||||
const getPartnerResponse = (): AnswerType => {
|
||||
if (match.partnerResponse) return match.partnerResponse;
|
||||
// Infer from match_type
|
||||
if (match.match_type === 'yes_yes') return 'yes';
|
||||
if (match.match_type === 'maybe_maybe') return 'maybe';
|
||||
// yes_maybe - partner could be either
|
||||
return 'maybe';
|
||||
};
|
||||
|
||||
const userResponse = getUserResponse();
|
||||
const partnerResponse = getPartnerResponse();
|
||||
|
||||
// Get match type styling
|
||||
const getMatchTypeLabel = () => {
|
||||
switch (match.match_type) {
|
||||
case 'yes_yes':
|
||||
return { text: 'Perfect Match', color: colors.primary };
|
||||
case 'yes_maybe':
|
||||
return { text: 'Curious Match', color: colors.warning };
|
||||
case 'maybe_maybe':
|
||||
return { text: 'Exploring Together', color: colors.premium.rose };
|
||||
default:
|
||||
return { text: 'Match', color: colors.primary };
|
||||
}
|
||||
};
|
||||
|
||||
const matchTypeInfo = getMatchTypeLabel();
|
||||
|
||||
// Use partner's name or fallback to "Partner"
|
||||
const partnerDisplayName = match.partnerName || 'Partner';
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[styles.overlay, { opacity: overlayOpacity }]}
|
||||
>
|
||||
{/* Background press to dismiss */}
|
||||
<Pressable style={StyleSheet.absoluteFill} onPress={handleDismiss} />
|
||||
|
||||
{/* Modal card with glow */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.glowContainer,
|
||||
{
|
||||
transform: [{ scale: modalScale }],
|
||||
opacity: modalOpacity,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{/* Outer glow effect */}
|
||||
<View style={styles.glowOuter} />
|
||||
|
||||
<View style={styles.modalContainer}>
|
||||
{/* Glass background */}
|
||||
<BlurView intensity={40} tint="dark" style={styles.blurContainer}>
|
||||
<LinearGradient
|
||||
colors={['rgba(22, 33, 62, 0.95)', 'rgba(13, 13, 26, 0.98)']}
|
||||
style={styles.gradientOverlay}
|
||||
/>
|
||||
</BlurView>
|
||||
|
||||
{/* Content */}
|
||||
<View style={styles.content}>
|
||||
{/* Title section */}
|
||||
<Text style={styles.title}>It's a Match!</Text>
|
||||
|
||||
{/* Match type badge */}
|
||||
<View style={[styles.matchTypeBadge, { borderColor: `${matchTypeInfo.color}30` }]}>
|
||||
<Text style={[styles.matchTypeText, { color: matchTypeInfo.color }]}>
|
||||
{matchTypeInfo.text}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<DecorativeSeparator variant="muted" width={80} marginVertical={spacing.md} />
|
||||
|
||||
{/* Question text and responses */}
|
||||
{match.question.partner_text ? (
|
||||
// Two-part question - show what each person saw
|
||||
<View style={styles.twoPartContainer}>
|
||||
<View style={styles.responseSection}>
|
||||
<View style={styles.responseLabelRow}>
|
||||
<Text style={styles.responsePersonLabel}>You said</Text>
|
||||
<ResponseBadge response={userResponse} />
|
||||
</View>
|
||||
<Text style={styles.questionTextSmall}>"{match.question.text}"</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.matchConnector}>
|
||||
<View style={styles.connectorLine} />
|
||||
<View style={styles.connectorHeart}>
|
||||
<Ionicons name="heart" size={16} color={colors.primary} />
|
||||
</View>
|
||||
<View style={styles.connectorLine} />
|
||||
</View>
|
||||
|
||||
<View style={styles.responseSection}>
|
||||
<View style={styles.responseLabelRow}>
|
||||
<Text style={styles.responsePersonLabel}>{partnerDisplayName} said</Text>
|
||||
<ResponseBadge response={partnerResponse} isPartner />
|
||||
</View>
|
||||
<Text style={styles.questionTextSmall}>"{match.question.partner_text}"</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
// Single question - show shared text with inline responses
|
||||
<View style={styles.questionContainer}>
|
||||
<Text style={styles.questionText}>"{match.question.text}"</Text>
|
||||
<View style={styles.responsesInline}>
|
||||
<Text style={styles.responseInlineText}>You said </Text>
|
||||
<Text style={[styles.responseInlineAnswer, { color: userResponse === 'yes' ? colors.primary : colors.warning }]}>
|
||||
{userResponse.toUpperCase()}
|
||||
</Text>
|
||||
<Text style={styles.responseInlineText}> · {partnerDisplayName} said </Text>
|
||||
<Text style={[styles.responseInlineAnswer, { color: partnerResponse === 'yes' ? colors.success : colors.warning }]}>
|
||||
{partnerResponse.toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<DecorativeSeparator variant="muted" width={100} marginVertical={spacing.md} />
|
||||
|
||||
{/* Action buttons */}
|
||||
<View style={styles.buttonRow}>
|
||||
<Pressable onPress={handleChat} style={styles.chatButton}>
|
||||
<LinearGradient
|
||||
colors={gradients.primary as [string, string]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.chatButtonGradient}
|
||||
>
|
||||
<Ionicons name="chatbubble" size={16} color={colors.text} />
|
||||
<Text style={styles.chatButtonText}>Chat</Text>
|
||||
</LinearGradient>
|
||||
</Pressable>
|
||||
|
||||
<Pressable onPress={handleDismiss} style={styles.dismissButton}>
|
||||
<Text style={styles.dismissButtonText}>Continue</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Border */}
|
||||
<View style={styles.border} pointerEvents="none" />
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 9999,
|
||||
},
|
||||
glowContainer: {
|
||||
width: '88%',
|
||||
maxWidth: 360,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
glowOuter: {
|
||||
position: 'absolute',
|
||||
top: -3,
|
||||
left: -3,
|
||||
right: -3,
|
||||
bottom: -3,
|
||||
borderRadius: radius.xl + 3,
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(233, 69, 96, 0.15)',
|
||||
shadowColor: colors.primary,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 20,
|
||||
elevation: 8,
|
||||
},
|
||||
modalContainer: {
|
||||
width: '100%',
|
||||
borderRadius: radius.xl,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
blurContainer: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
gradientOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
content: {
|
||||
padding: spacing.xl,
|
||||
paddingTop: spacing.lg,
|
||||
alignItems: 'center',
|
||||
},
|
||||
title: {
|
||||
...typography.title2,
|
||||
color: colors.text,
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
matchTypeBadge: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.full,
|
||||
borderWidth: 1,
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
matchTypeText: {
|
||||
...typography.caption1,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 1,
|
||||
},
|
||||
questionContainer: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
questionText: {
|
||||
...typography.body,
|
||||
color: colors.text,
|
||||
textAlign: 'center',
|
||||
fontStyle: 'italic',
|
||||
lineHeight: 24,
|
||||
paddingHorizontal: spacing.sm,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
responsesInline: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
responseInlineText: {
|
||||
...typography.caption1,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
responseInlineAnswer: {
|
||||
...typography.caption1,
|
||||
fontWeight: '700',
|
||||
},
|
||||
responseBadgeCompact: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
},
|
||||
responseBadgeTextCompact: {
|
||||
...typography.caption1,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
// Two-part question styles
|
||||
twoPartContainer: {
|
||||
width: '100%',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
responseSection: {
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.sm,
|
||||
},
|
||||
responseLabelRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
responsePersonLabel: {
|
||||
...typography.caption1,
|
||||
color: colors.textSecondary,
|
||||
fontWeight: '500',
|
||||
},
|
||||
questionTextSmall: {
|
||||
...typography.subhead,
|
||||
color: colors.text,
|
||||
textAlign: 'center',
|
||||
fontStyle: 'italic',
|
||||
lineHeight: 20,
|
||||
},
|
||||
matchConnector: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginVertical: spacing.md,
|
||||
paddingHorizontal: spacing.xl,
|
||||
},
|
||||
connectorLine: {
|
||||
flex: 1,
|
||||
height: 1,
|
||||
backgroundColor: 'rgba(233, 69, 96, 0.2)',
|
||||
},
|
||||
connectorHeart: {
|
||||
marginHorizontal: spacing.sm,
|
||||
opacity: 0.8,
|
||||
},
|
||||
buttonRow: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.md,
|
||||
width: '100%',
|
||||
},
|
||||
chatButton: {
|
||||
flex: 1,
|
||||
borderRadius: radius.lg,
|
||||
overflow: 'hidden',
|
||||
...shadows.md,
|
||||
},
|
||||
chatButtonGradient: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
chatButtonText: {
|
||||
...typography.headline,
|
||||
color: colors.text,
|
||||
},
|
||||
dismissButton: {
|
||||
flex: 1,
|
||||
borderRadius: radius.lg,
|
||||
backgroundColor: colors.glass.background,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
paddingVertical: spacing.md,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
dismissButtonText: {
|
||||
...typography.headline,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
border: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
borderRadius: radius.xl,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(233, 69, 96, 0.2)',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import React from "react";
|
||||
import { View, Text, StyleSheet, Platform } from "react-native";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withSpring,
|
||||
runOnJS,
|
||||
interpolate,
|
||||
Extrapolation,
|
||||
} from "react-native-reanimated";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { colors, spacing, radius, typography } from "../../theme";
|
||||
|
||||
const SWIPE_THRESHOLD = 80;
|
||||
const ACCENT = colors.premium.gold;
|
||||
|
||||
// Haptics helper
|
||||
const triggerHaptic = async (style: 'light' | 'medium' | 'heavy' = 'medium') => {
|
||||
if (Platform.OS === 'web') return;
|
||||
const Haptics = await import('expo-haptics');
|
||||
const feedbackStyle = style === 'light'
|
||||
? Haptics.ImpactFeedbackStyle.Light
|
||||
: style === 'heavy'
|
||||
? Haptics.ImpactFeedbackStyle.Heavy
|
||||
: Haptics.ImpactFeedbackStyle.Medium;
|
||||
await Haptics.impactAsync(feedbackStyle);
|
||||
};
|
||||
|
||||
interface SwipeableMatchItemProps {
|
||||
children: React.ReactNode;
|
||||
onArchive: () => void;
|
||||
isArchived?: boolean;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function SwipeableMatchItem({
|
||||
children,
|
||||
onArchive,
|
||||
isArchived = false,
|
||||
enabled = true,
|
||||
}: SwipeableMatchItemProps) {
|
||||
const translateX = useSharedValue(0);
|
||||
const hasTriggeredHaptic = useSharedValue(false);
|
||||
|
||||
const panGesture = Gesture.Pan()
|
||||
.enabled(enabled)
|
||||
.activeOffsetX([-20, 20])
|
||||
.onUpdate((event) => {
|
||||
// Only allow left swipe (negative X) for archive
|
||||
translateX.value = Math.min(0, event.translationX);
|
||||
|
||||
// Trigger haptic when crossing threshold
|
||||
if (translateX.value < -SWIPE_THRESHOLD && !hasTriggeredHaptic.value) {
|
||||
hasTriggeredHaptic.value = true;
|
||||
runOnJS(triggerHaptic)('medium');
|
||||
} else if (translateX.value >= -SWIPE_THRESHOLD) {
|
||||
hasTriggeredHaptic.value = false;
|
||||
}
|
||||
})
|
||||
.onEnd(() => {
|
||||
if (translateX.value < -SWIPE_THRESHOLD) {
|
||||
// Animate out then trigger archive
|
||||
translateX.value = withSpring(-200, { damping: 15 }, (finished) => {
|
||||
if (finished) {
|
||||
runOnJS(onArchive)();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Spring back to center
|
||||
translateX.value = withSpring(0, { damping: 15 });
|
||||
}
|
||||
hasTriggeredHaptic.value = false;
|
||||
});
|
||||
|
||||
const containerStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateX: translateX.value }],
|
||||
}));
|
||||
|
||||
const actionStyle = useAnimatedStyle(() => ({
|
||||
opacity: interpolate(
|
||||
translateX.value,
|
||||
[-SWIPE_THRESHOLD, -20, 0],
|
||||
[1, 0.5, 0],
|
||||
Extrapolation.CLAMP
|
||||
),
|
||||
transform: [
|
||||
{
|
||||
scale: interpolate(
|
||||
translateX.value,
|
||||
[-SWIPE_THRESHOLD, 0],
|
||||
[1, 0.8],
|
||||
Extrapolation.CLAMP
|
||||
),
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const actionIconStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{
|
||||
translateX: interpolate(
|
||||
translateX.value,
|
||||
[-SWIPE_THRESHOLD * 1.5, -SWIPE_THRESHOLD, 0],
|
||||
[0, 10, 30],
|
||||
Extrapolation.CLAMP
|
||||
),
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Archive action revealed on swipe */}
|
||||
<Animated.View style={[styles.actionContainer, actionStyle]}>
|
||||
<Animated.View style={[styles.actionContent, actionIconStyle]}>
|
||||
<View style={styles.iconContainer}>
|
||||
<Ionicons
|
||||
name={isArchived ? "arrow-undo" : "archive"}
|
||||
size={22}
|
||||
color={ACCENT}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.actionText}>
|
||||
{isArchived ? "Restore" : "Archive"}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
|
||||
<GestureDetector gesture={panGesture}>
|
||||
<Animated.View style={containerStyle}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
},
|
||||
actionContainer: {
|
||||
position: "absolute",
|
||||
right: spacing.md,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
justifyContent: "center",
|
||||
alignItems: "flex-end",
|
||||
paddingRight: spacing.sm,
|
||||
},
|
||||
actionContent: {
|
||||
alignItems: "center",
|
||||
gap: spacing.xs,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: `rgba(212, 175, 55, 0.15)`,
|
||||
borderWidth: 1,
|
||||
borderColor: `rgba(212, 175, 55, 0.3)`,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
actionText: {
|
||||
...typography.caption2,
|
||||
color: ACCENT,
|
||||
fontWeight: "600",
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { SwipeableMatchItem } from './SwipeableMatchItem';
|
||||
@@ -1,21 +1,18 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { View, Text, StyleSheet, TouchableOpacity, KeyboardAvoidingView, Platform, ActivityIndicator, Modal, Dimensions, Alert } from "react-native";
|
||||
import { Image } from "expo-image";
|
||||
import { Video, ResizeMode } from "expo-av";
|
||||
import { View, StyleSheet, KeyboardAvoidingView, Platform, Alert } from "react-native";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useFocusEffect, useIsFocused } from "@react-navigation/native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import Animated, { FadeInDown, useAnimatedStyle } from "react-native-reanimated";
|
||||
import Animated, { FadeInDown } from "react-native-reanimated";
|
||||
|
||||
import { InputBar, ChatHeader, ChatMessages, ReportMessageSheet } from "./components";
|
||||
import { InputBar, ChatHeader, ChatMessages, ReportMessageSheet, ChatSettingsSheet, FullScreenImageModal, FullScreenVideoModal } from "./components";
|
||||
import { useMediaUpload } from "./hooks/useMediaUpload";
|
||||
import { useMessageActions } from "./hooks/useMessageActions";
|
||||
import type { ReportReason } from "./types";
|
||||
import type { ReportReason, Match } from "./types";
|
||||
|
||||
import { supabase } from "../../lib/supabase";
|
||||
import { useAuthStore, useMessageStore } from "../../store";
|
||||
import { useAuthStore, useMessageStore, useMatchStore } from "../../store";
|
||||
import { useAmbientOrbAnimation, useMediaPicker, useTypingIndicator, useMessageSubscription, useMediaSaver } from "../../hooks";
|
||||
import { Database } from "../../types/supabase";
|
||||
import { GradientBackground } from "../../components/ui";
|
||||
@@ -25,7 +22,6 @@ import { Events } from "../../lib/analytics";
|
||||
type Message = Database["public"]["Tables"]["messages"]["Row"];
|
||||
|
||||
// Premium color palette for Chat
|
||||
const ACCENT = colors.premium.gold;
|
||||
const ROSE_RGBA = 'rgba(232, 164, 174, ';
|
||||
const ACCENT_RGBA = 'rgba(212, 175, 55, ';
|
||||
|
||||
@@ -36,8 +32,10 @@ export const ChatScreen: React.FC = () => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { user, partner } = useAuthStore();
|
||||
const { setActiveMatchId, markMatchMessagesAsRead } = useMessageStore();
|
||||
const [match, setMatch] = useState<any>(null);
|
||||
const { archiveMatch, unarchiveMatch, isMatchArchived } = useMatchStore();
|
||||
const [match, setMatch] = useState<Match | null>(null);
|
||||
const [inputText, setInputText] = useState("");
|
||||
const [showSettingsSheet, setShowSettingsSheet] = useState(false);
|
||||
const { uploading, uploadStatus, uploadMedia } = useMediaUpload(matchId, user?.id);
|
||||
const { saveMedia, saving: mediaSaving } = useMediaSaver();
|
||||
const [fullScreenImage, setFullScreenImage] = useState<string | null>(null);
|
||||
@@ -81,6 +79,62 @@ export const ChatScreen: React.FC = () => {
|
||||
onReport: (message) => setReportingMessage(message),
|
||||
});
|
||||
|
||||
// Memoized navigation callbacks
|
||||
const handleBack = useCallback(() => {
|
||||
router.push('/(app)/matches');
|
||||
}, [router]);
|
||||
|
||||
const handleOpenSettings = useCallback(() => {
|
||||
setShowSettingsSheet(true);
|
||||
}, []);
|
||||
|
||||
const handleCloseSettings = useCallback(() => {
|
||||
setShowSettingsSheet(false);
|
||||
}, []);
|
||||
|
||||
// Memoized modal callbacks
|
||||
const handleCloseImage = useCallback(() => {
|
||||
setFullScreenImage(null);
|
||||
}, []);
|
||||
|
||||
const handleSaveImage = useCallback(() => {
|
||||
if (fullScreenImage) {
|
||||
saveMedia(fullScreenImage, 'image');
|
||||
}
|
||||
}, [fullScreenImage, saveMedia]);
|
||||
|
||||
const handleImageLoadStart = useCallback(() => {
|
||||
setFullScreenImageLoading(true);
|
||||
}, []);
|
||||
|
||||
const handleImageLoadEnd = useCallback(() => {
|
||||
setFullScreenImageLoading(false);
|
||||
}, []);
|
||||
|
||||
const handleCloseVideo = useCallback(() => {
|
||||
setFullScreenVideo(null);
|
||||
}, []);
|
||||
|
||||
const handleSaveVideo = useCallback(() => {
|
||||
if (fullScreenVideo) {
|
||||
saveMedia(fullScreenVideo, 'video');
|
||||
}
|
||||
}, [fullScreenVideo, saveMedia]);
|
||||
|
||||
const handleCloseReport = useCallback(() => {
|
||||
setReportingMessage(null);
|
||||
}, []);
|
||||
|
||||
// Memoized archive/unarchive callbacks
|
||||
const handleArchive = useCallback(async () => {
|
||||
await archiveMatch(matchId);
|
||||
router.push('/(app)/matches');
|
||||
}, [archiveMatch, matchId, router]);
|
||||
|
||||
const handleUnarchive = useCallback(async () => {
|
||||
await unarchiveMatch(matchId);
|
||||
}, [unarchiveMatch, matchId]);
|
||||
|
||||
// Handle long press on message to show options
|
||||
const handleMessageLongPress = useCallback((message: Message, isMe: boolean) => {
|
||||
// Don't show options for already deleted messages
|
||||
@@ -89,7 +143,7 @@ export const ChatScreen: React.FC = () => {
|
||||
}, [showDeleteOptions]);
|
||||
|
||||
// Handle report submission
|
||||
const handleSubmitReport = async (reason: ReportReason) => {
|
||||
const handleSubmitReport = useCallback(async (reason: ReportReason) => {
|
||||
if (!reportingMessage || !user?.id) return;
|
||||
|
||||
try {
|
||||
@@ -127,7 +181,7 @@ export const ChatScreen: React.FC = () => {
|
||||
Alert.alert('Error', 'Failed to submit report');
|
||||
setReportingMessage(null);
|
||||
}
|
||||
};
|
||||
}, [reportingMessage, user?.id, deleteForSelf, setMessages]);
|
||||
|
||||
// Track active chat to prevent notifications for current chat
|
||||
useFocusEffect(
|
||||
@@ -169,15 +223,15 @@ export const ChatScreen: React.FC = () => {
|
||||
fetchMatch();
|
||||
}, [matchId]);
|
||||
|
||||
const handleTyping = (text: string) => {
|
||||
const handleTyping = useCallback((text: string) => {
|
||||
setInputText(text);
|
||||
|
||||
if (text.length > 0) {
|
||||
sendTypingEvent();
|
||||
}
|
||||
};
|
||||
}, [sendTypingEvent]);
|
||||
|
||||
const handleSend = async () => {
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!inputText.trim()) return;
|
||||
if (!user) return;
|
||||
|
||||
@@ -204,30 +258,30 @@ export const ChatScreen: React.FC = () => {
|
||||
Alert.alert("Delivery Failed", "Couldn't send message. Please check your connection.");
|
||||
setInputText(content);
|
||||
}
|
||||
};
|
||||
}, [inputText, user, matchId]);
|
||||
|
||||
const handlePickMedia = async () => {
|
||||
const handlePickMedia = useCallback(async () => {
|
||||
const result = await pickMedia();
|
||||
if (result) {
|
||||
uploadMedia(result.uri, result.mediaType);
|
||||
}
|
||||
};
|
||||
}, [pickMedia, uploadMedia]);
|
||||
|
||||
const handleTakePhoto = async () => {
|
||||
const handleTakePhoto = useCallback(async () => {
|
||||
const result = await takePhoto();
|
||||
if (result) {
|
||||
uploadMedia(result.uri, 'image');
|
||||
}
|
||||
};
|
||||
}, [takePhoto, uploadMedia]);
|
||||
|
||||
const handleRecordVideo = async () => {
|
||||
const handleRecordVideo = useCallback(async () => {
|
||||
const result = await recordVideo();
|
||||
if (result) {
|
||||
uploadMedia(result.uri, 'video');
|
||||
}
|
||||
};
|
||||
}, [recordVideo, uploadMedia]);
|
||||
|
||||
const revealMessage = async (messageId: string) => {
|
||||
const revealMessage = useCallback(async (messageId: string) => {
|
||||
const message = messages.find(m => m.id === messageId);
|
||||
const now = new Date();
|
||||
// Set expiration 30 days from now for videos only
|
||||
@@ -250,7 +304,7 @@ export const ChatScreen: React.FC = () => {
|
||||
media_expires_at: expiresAt
|
||||
})
|
||||
.eq("id", messageId);
|
||||
};
|
||||
}, [messages, setMessages]);
|
||||
|
||||
return (
|
||||
<GradientBackground>
|
||||
@@ -280,10 +334,9 @@ export const ChatScreen: React.FC = () => {
|
||||
|
||||
<ChatHeader
|
||||
partner={partner}
|
||||
user={user}
|
||||
match={match}
|
||||
insets={insets}
|
||||
onBack={() => router.push('/(app)/matches')}
|
||||
onBack={handleBack}
|
||||
onSettingsPress={handleOpenSettings}
|
||||
/>
|
||||
|
||||
|
||||
@@ -327,124 +380,41 @@ export const ChatScreen: React.FC = () => {
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
{/* Full Screen Image Modal */}
|
||||
<Modal
|
||||
<FullScreenImageModal
|
||||
uri={fullScreenImage}
|
||||
visible={!!fullScreenImage}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setFullScreenImage(null)}
|
||||
>
|
||||
<View style={styles.fullScreenOverlay}>
|
||||
<TouchableOpacity
|
||||
style={styles.fullScreenSaveButton}
|
||||
onPress={() => fullScreenImage && saveMedia(fullScreenImage, 'image')}
|
||||
activeOpacity={0.8}
|
||||
disabled={mediaSaving}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={gradients.premiumGold as [string, string]}
|
||||
style={styles.fullScreenCloseGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
{mediaSaving ? (
|
||||
<ActivityIndicator size="small" color={colors.text} />
|
||||
) : (
|
||||
<Ionicons name="download-outline" size={24} color={colors.text} />
|
||||
)}
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.fullScreenCloseButton}
|
||||
onPress={() => setFullScreenImage(null)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={gradients.premiumGold as [string, string]}
|
||||
style={styles.fullScreenCloseGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<Ionicons name="close" size={24} color={colors.text} />
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
{fullScreenImageLoading && (
|
||||
<ActivityIndicator size="large" color={ACCENT} style={styles.fullScreenSpinner} />
|
||||
)}
|
||||
{fullScreenImage && (
|
||||
<Image
|
||||
source={{ uri: fullScreenImage }}
|
||||
style={styles.fullScreenImage}
|
||||
contentFit="contain"
|
||||
cachePolicy="disk"
|
||||
onLoadStart={() => setFullScreenImageLoading(true)}
|
||||
onLoadEnd={() => setFullScreenImageLoading(false)}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</Modal>
|
||||
loading={fullScreenImageLoading}
|
||||
saving={mediaSaving}
|
||||
onClose={handleCloseImage}
|
||||
onSave={handleSaveImage}
|
||||
onLoadStart={handleImageLoadStart}
|
||||
onLoadEnd={handleImageLoadEnd}
|
||||
/>
|
||||
|
||||
{/* Full Screen Video Modal */}
|
||||
<Modal
|
||||
<FullScreenVideoModal
|
||||
uri={fullScreenVideo}
|
||||
visible={!!fullScreenVideo}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setFullScreenVideo(null)}
|
||||
>
|
||||
<View style={styles.fullScreenOverlay}>
|
||||
<TouchableOpacity
|
||||
style={styles.fullScreenSaveButton}
|
||||
onPress={() => fullScreenVideo && saveMedia(fullScreenVideo, 'video')}
|
||||
activeOpacity={0.8}
|
||||
disabled={mediaSaving}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={gradients.premiumGold as [string, string]}
|
||||
style={styles.fullScreenCloseGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
{mediaSaving ? (
|
||||
<ActivityIndicator size="small" color={colors.text} />
|
||||
) : (
|
||||
<Ionicons name="download-outline" size={24} color={colors.text} />
|
||||
)}
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.fullScreenCloseButton}
|
||||
onPress={() => setFullScreenVideo(null)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={gradients.premiumGold as [string, string]}
|
||||
style={styles.fullScreenCloseGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<Ionicons name="close" size={24} color={colors.text} />
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
{fullScreenVideo && (
|
||||
<Video
|
||||
source={{ uri: fullScreenVideo }}
|
||||
style={styles.fullScreenVideo}
|
||||
resizeMode={ResizeMode.CONTAIN}
|
||||
useNativeControls
|
||||
shouldPlay
|
||||
isLooping={false}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</Modal>
|
||||
saving={mediaSaving}
|
||||
onClose={handleCloseVideo}
|
||||
onSave={handleSaveVideo}
|
||||
/>
|
||||
|
||||
{/* Report Message Sheet */}
|
||||
<ReportMessageSheet
|
||||
visible={reportingMessage !== null}
|
||||
onClose={() => setReportingMessage(null)}
|
||||
onClose={handleCloseReport}
|
||||
onSubmit={handleSubmitReport}
|
||||
/>
|
||||
|
||||
{/* Chat Settings Sheet */}
|
||||
<ChatSettingsSheet
|
||||
visible={showSettingsSheet}
|
||||
onClose={handleCloseSettings}
|
||||
isArchived={isMatchArchived(matchId)}
|
||||
onArchive={handleArchive}
|
||||
onUnarchive={handleUnarchive}
|
||||
/>
|
||||
</GradientBackground>
|
||||
);
|
||||
};
|
||||
@@ -473,43 +443,6 @@ const styles = StyleSheet.create({
|
||||
height: '100%',
|
||||
borderRadius: 140,
|
||||
},
|
||||
// Full Screen Modal
|
||||
fullScreenOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.95)",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
fullScreenCloseButton: {
|
||||
position: "absolute",
|
||||
top: 50,
|
||||
right: 20,
|
||||
zIndex: 10,
|
||||
},
|
||||
fullScreenSaveButton: {
|
||||
position: "absolute",
|
||||
top: 50,
|
||||
left: 20,
|
||||
zIndex: 10,
|
||||
},
|
||||
fullScreenCloseGradient: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
fullScreenImage: {
|
||||
width: Dimensions.get("window").width,
|
||||
height: Dimensions.get("window").height * 0.8,
|
||||
},
|
||||
fullScreenVideo: {
|
||||
width: Dimensions.get("window").width,
|
||||
height: Dimensions.get("window").height * 0.8,
|
||||
},
|
||||
fullScreenSpinner: {
|
||||
position: "absolute",
|
||||
},
|
||||
inputWrapper: {
|
||||
overflow: 'hidden',
|
||||
},
|
||||
|
||||
@@ -15,16 +15,16 @@ const ACCENT_RGBA = 'rgba(212, 175, 55, ';
|
||||
|
||||
interface ChatHeaderProps {
|
||||
partner: Profile | null;
|
||||
user: { id: string } | null;
|
||||
match: any; // Kept for compatibility but unused
|
||||
insets: EdgeInsets;
|
||||
onBack: () => void;
|
||||
onSettingsPress?: () => void;
|
||||
}
|
||||
|
||||
export const ChatHeader: React.FC<ChatHeaderProps> = ({
|
||||
const ChatHeaderComponent: React.FC<ChatHeaderProps> = ({
|
||||
partner,
|
||||
insets,
|
||||
onBack,
|
||||
onSettingsPress,
|
||||
}) => {
|
||||
return (
|
||||
<Animated.View
|
||||
@@ -50,6 +50,8 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
|
||||
onPress={onBack}
|
||||
style={styles.backButton}
|
||||
activeOpacity={0.7}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Go back to matches"
|
||||
>
|
||||
<View style={styles.backButtonInner}>
|
||||
<Ionicons name="chevron-back" size={20} color={ACCENT} />
|
||||
@@ -86,7 +88,21 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.headerSpacer} />
|
||||
{onSettingsPress ? (
|
||||
<TouchableOpacity
|
||||
onPress={onSettingsPress}
|
||||
style={styles.settingsButton}
|
||||
activeOpacity={0.7}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Chat settings"
|
||||
>
|
||||
<View style={styles.backButtonInner}>
|
||||
<Ionicons name="ellipsis-horizontal" size={20} color={ACCENT} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<View style={styles.headerSpacer} />
|
||||
)}
|
||||
|
||||
{/* Bottom border */}
|
||||
<View style={styles.headerBorderBottom} />
|
||||
@@ -94,6 +110,9 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
// Wrap with React.memo for performance
|
||||
export const ChatHeader = React.memo(ChatHeaderComponent);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
@@ -179,6 +198,12 @@ const styles = StyleSheet.create({
|
||||
headerSpacer: {
|
||||
width: 40,
|
||||
},
|
||||
settingsButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
headerBorderBottom: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useRef } from 'react';
|
||||
import React, { useRef, useCallback, useMemo } from 'react';
|
||||
import { View, Text, FlatList, StyleSheet } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import Animated, { FadeIn } from 'react-native-reanimated';
|
||||
@@ -12,14 +12,14 @@ import { MatchCard } from './MatchCard';
|
||||
import { DecorativeSeparator } from '../../../components/ui';
|
||||
import { colors, spacing, typography } from '../../../theme';
|
||||
import { Database } from '../../../types/supabase';
|
||||
import { UploadStatus } from '../types';
|
||||
import { UploadStatus, Match } from '../types';
|
||||
|
||||
type Message = Database['public']['Tables']['messages']['Row'];
|
||||
|
||||
interface ChatMessagesProps {
|
||||
messages: Message[];
|
||||
userId: string | undefined;
|
||||
match: any;
|
||||
match: Match | null;
|
||||
uploadStatus: UploadStatus | null;
|
||||
partnerTyping: boolean;
|
||||
onImagePress: (uri: string) => void;
|
||||
@@ -43,7 +43,7 @@ function isSameDay(date1: Date, date2: Date): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export const ChatMessages: React.FC<ChatMessagesProps> = ({
|
||||
const ChatMessagesComponent: React.FC<ChatMessagesProps> = ({
|
||||
messages,
|
||||
userId,
|
||||
match,
|
||||
@@ -56,7 +56,14 @@ export const ChatMessages: React.FC<ChatMessagesProps> = ({
|
||||
}) => {
|
||||
const flatListRef = useRef<FlatList>(null);
|
||||
|
||||
const renderMessage = ({ item, index }: { item: Message; index: number }) => {
|
||||
// Memoize keyExtractor to prevent recreation
|
||||
const keyExtractor = useCallback((item: Message) => item.id, []);
|
||||
|
||||
// Memoize user object for MatchCard to prevent re-renders
|
||||
const userObject = useMemo(() => ({ id: userId || '' }), [userId]);
|
||||
|
||||
// Memoize renderMessage to prevent recreation on every render
|
||||
const renderMessage = useCallback(({ item, index }: { item: Message; index: number }) => {
|
||||
const isMe = item.user_id === userId;
|
||||
const messageDate = new Date(item.created_at!);
|
||||
|
||||
@@ -70,7 +77,7 @@ export const ChatMessages: React.FC<ChatMessagesProps> = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<View>
|
||||
<MessageBubble
|
||||
isMe={isMe}
|
||||
index={index}
|
||||
@@ -86,58 +93,68 @@ export const ChatMessages: React.FC<ChatMessagesProps> = ({
|
||||
/>
|
||||
</MessageBubble>
|
||||
{showDateSeparator && <DateSeparator date={messageDate} />}
|
||||
</>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
}, [messages, userId, onMessageLongPress, revealMessage, onImagePress, onVideoFullScreen]);
|
||||
|
||||
// Memoize ListHeaderComponent to prevent recreation
|
||||
const listHeaderComponent = useMemo(() => (
|
||||
<View>
|
||||
{uploadStatus && <UploadProgress uploadStatus={uploadStatus} />}
|
||||
{partnerTyping && <TypingIndicator />}
|
||||
</View>
|
||||
), [uploadStatus, partnerTyping]);
|
||||
|
||||
// Memoize ListFooterComponent to prevent recreation
|
||||
const listFooterComponent = useMemo(() => (
|
||||
<View style={styles.footerContainer}>
|
||||
<MatchCard match={match} user={userObject} />
|
||||
</View>
|
||||
), [match, userObject]);
|
||||
|
||||
// Memoize ListEmptyComponent to prevent recreation
|
||||
const listEmptyComponent = useMemo(() => (
|
||||
<Animated.View
|
||||
entering={FadeIn.delay(200).duration(400)}
|
||||
style={styles.emptyChat}
|
||||
>
|
||||
<View style={styles.emptyIconContainer}>
|
||||
<Ionicons name="chatbubbles-outline" size={32} color={ACCENT} />
|
||||
</View>
|
||||
<DecorativeSeparator variant="gold" width={100} marginVertical={spacing.md} />
|
||||
<Text style={styles.emptyChatTitle}>Start the Conversation</Text>
|
||||
<Text style={styles.emptyChatSubtitle}>
|
||||
Share your thoughts about this match
|
||||
</Text>
|
||||
</Animated.View>
|
||||
), []);
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={messages}
|
||||
renderItem={renderMessage}
|
||||
keyExtractor={(item) => item.id}
|
||||
keyExtractor={keyExtractor}
|
||||
inverted
|
||||
style={styles.messageList}
|
||||
contentContainerStyle={styles.listContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
ListHeaderComponent={
|
||||
<>
|
||||
{/* Show uploading status when media is being uploaded */}
|
||||
{uploadStatus && <UploadProgress uploadStatus={uploadStatus} />}
|
||||
|
||||
{/* Show typing indicator when partner is typing */}
|
||||
{partnerTyping && <TypingIndicator />}
|
||||
</>
|
||||
}
|
||||
ListFooterComponent={
|
||||
<View style={{ paddingBottom: spacing.md }}>
|
||||
<MatchCard match={match} user={{ id: userId || '' }} />
|
||||
</View>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
<Animated.View
|
||||
entering={FadeIn.delay(200).duration(400)}
|
||||
style={styles.emptyChat}
|
||||
>
|
||||
{/* Premium icon */}
|
||||
<View style={styles.emptyIconContainer}>
|
||||
<Ionicons name="chatbubbles-outline" size={32} color={ACCENT} />
|
||||
</View>
|
||||
|
||||
{/* Decorative separator */}
|
||||
<DecorativeSeparator variant="gold" width={100} marginVertical={spacing.md} />
|
||||
|
||||
<Text style={styles.emptyChatTitle}>Start the Conversation</Text>
|
||||
<Text style={styles.emptyChatSubtitle}>
|
||||
Share your thoughts about this match
|
||||
</Text>
|
||||
</Animated.View>
|
||||
}
|
||||
// Performance optimizations
|
||||
removeClippedSubviews={true}
|
||||
maxToRenderPerBatch={15}
|
||||
windowSize={10}
|
||||
initialNumToRender={20}
|
||||
ListHeaderComponent={listHeaderComponent}
|
||||
ListFooterComponent={listFooterComponent}
|
||||
ListEmptyComponent={listEmptyComponent}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Wrap with React.memo for performance
|
||||
export const ChatMessages = React.memo(ChatMessagesComponent);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
messageList: {
|
||||
flex: 1,
|
||||
@@ -146,6 +163,9 @@ const styles = StyleSheet.create({
|
||||
flexGrow: 1,
|
||||
padding: spacing.md,
|
||||
},
|
||||
footerContainer: {
|
||||
paddingBottom: spacing.md,
|
||||
},
|
||||
emptyChat: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Modal,
|
||||
TouchableOpacity,
|
||||
TouchableWithoutFeedback,
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BlurView } from "expo-blur";
|
||||
import Animated, { FadeIn, FadeOut, SlideInDown, SlideOutDown } from "react-native-reanimated";
|
||||
import { colors, spacing, typography, radius } from "../../../theme";
|
||||
|
||||
interface ChatSettingsSheetProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
isArchived: boolean;
|
||||
onArchive: () => Promise<void>;
|
||||
onUnarchive: () => Promise<void>;
|
||||
}
|
||||
|
||||
const ACCENT = colors.premium.gold;
|
||||
|
||||
export function ChatSettingsSheet({
|
||||
visible,
|
||||
onClose,
|
||||
isArchived,
|
||||
onArchive,
|
||||
onUnarchive,
|
||||
}: ChatSettingsSheetProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleArchiveToggle = async () => {
|
||||
if (isLoading) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (isArchived) {
|
||||
await onUnarchive();
|
||||
} else {
|
||||
await onArchive();
|
||||
}
|
||||
onClose();
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="none"
|
||||
onRequestClose={onClose}
|
||||
statusBarTranslucent
|
||||
>
|
||||
<Animated.View style={styles.overlay} entering={FadeIn.duration(200)} exiting={FadeOut.duration(200)}>
|
||||
<TouchableWithoutFeedback onPress={onClose}>
|
||||
<View style={styles.backdrop} />
|
||||
</TouchableWithoutFeedback>
|
||||
|
||||
<Animated.View
|
||||
style={styles.sheetContainer}
|
||||
entering={SlideInDown.springify().damping(20)}
|
||||
exiting={SlideOutDown.duration(200)}
|
||||
>
|
||||
{Platform.OS === "ios" ? (
|
||||
<BlurView intensity={80} tint="dark" style={styles.sheet}>
|
||||
<SheetContent
|
||||
isArchived={isArchived}
|
||||
isLoading={isLoading}
|
||||
onClose={onClose}
|
||||
onArchiveToggle={handleArchiveToggle}
|
||||
/>
|
||||
</BlurView>
|
||||
) : (
|
||||
<View style={[styles.sheet, styles.sheetAndroid]}>
|
||||
<SheetContent
|
||||
isArchived={isArchived}
|
||||
isLoading={isLoading}
|
||||
onClose={onClose}
|
||||
onArchiveToggle={handleArchiveToggle}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
isArchived,
|
||||
isLoading,
|
||||
onClose,
|
||||
onArchiveToggle,
|
||||
}: {
|
||||
isArchived: boolean;
|
||||
isLoading: boolean;
|
||||
onClose: () => void;
|
||||
onArchiveToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{/* Handle */}
|
||||
<View style={styles.handle} />
|
||||
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>Chat Settings</Text>
|
||||
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
|
||||
<Ionicons name="close" size={20} color={colors.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Archive option */}
|
||||
<TouchableOpacity
|
||||
style={styles.optionButton}
|
||||
onPress={onArchiveToggle}
|
||||
disabled={isLoading}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.optionContent}>
|
||||
<View style={styles.optionIconContainer}>
|
||||
<Ionicons
|
||||
name={isArchived ? "arrow-undo" : "archive"}
|
||||
size={22}
|
||||
color={ACCENT}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.optionTextContainer}>
|
||||
<Text style={styles.optionTitle}>
|
||||
{isArchived ? "Unarchive Chat" : "Archive Chat"}
|
||||
</Text>
|
||||
<Text style={styles.optionDescription}>
|
||||
{isArchived
|
||||
? "Move this chat back to your main list"
|
||||
: "Hide this chat from your main list"}
|
||||
</Text>
|
||||
</View>
|
||||
{isLoading && (
|
||||
<ActivityIndicator size="small" color={ACCENT} style={styles.optionLoader} />
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Bottom padding for safe area */}
|
||||
<View style={styles.bottomPadding} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
backdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.6)",
|
||||
},
|
||||
sheetContainer: {
|
||||
maxHeight: "80%",
|
||||
},
|
||||
sheet: {
|
||||
borderTopLeftRadius: radius.xl,
|
||||
borderTopRightRadius: radius.xl,
|
||||
overflow: "hidden",
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.md,
|
||||
},
|
||||
sheetAndroid: {
|
||||
backgroundColor: colors.backgroundLight,
|
||||
},
|
||||
handle: {
|
||||
width: 40,
|
||||
height: 4,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.3)",
|
||||
borderRadius: 2,
|
||||
alignSelf: "center",
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
headerTitle: {
|
||||
...typography.title3,
|
||||
color: colors.text,
|
||||
},
|
||||
closeButton: {
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.1)",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
optionButton: {
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: radius.lg,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.05)",
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(255, 255, 255, 0.1)",
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
optionContent: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
optionIconContainer: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: `rgba(212, 175, 55, 0.1)`,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
optionTextContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
optionTitle: {
|
||||
...typography.callout,
|
||||
color: colors.text,
|
||||
fontWeight: "600",
|
||||
marginBottom: 2,
|
||||
},
|
||||
optionDescription: {
|
||||
...typography.caption1,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
optionLoader: {
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
bottomPadding: {
|
||||
height: Platform.OS === "ios" ? 34 : 20,
|
||||
},
|
||||
});
|
||||
@@ -24,12 +24,12 @@ const ACCENT = colors.premium.gold;
|
||||
* Video player component with native-like tap behavior.
|
||||
* Supports play/pause, fullscreen, caching, and loading states.
|
||||
*/
|
||||
export function ChatVideoPlayer({
|
||||
const ChatVideoPlayerComponent: React.FC<ChatVideoPlayerProps> = ({
|
||||
signedUrl,
|
||||
storagePath,
|
||||
urlError,
|
||||
onFullScreen,
|
||||
}: ChatVideoPlayerProps) {
|
||||
}) => {
|
||||
const videoRef = useRef<Video>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [hasEnded, setHasEnded] = useState(false);
|
||||
@@ -43,11 +43,6 @@ export function ChatVideoPlayer({
|
||||
const isLocalFile = signedUrl?.startsWith('file://');
|
||||
const videoSource = isLocalFile ? signedUrl : (cachedUri || signedUrl);
|
||||
|
||||
// Debug logging
|
||||
useEffect(() => {
|
||||
console.log(`[ChatVideoPlayer] isLocalFile=${isLocalFile}, storagePath=${storagePath}, signedUrl=${signedUrl?.substring(0, 60)}..., videoSource=${videoSource?.substring(0, 60)}...`);
|
||||
}, [storagePath, signedUrl, videoSource, isLocalFile]);
|
||||
|
||||
// Handle playback status updates
|
||||
const handlePlaybackStatusUpdate = useCallback(async (status: AVPlaybackStatus) => {
|
||||
if (!status.isLoaded) {
|
||||
@@ -71,7 +66,7 @@ export function ChatVideoPlayer({
|
||||
cacheVideoFile();
|
||||
}
|
||||
}
|
||||
}, [signedUrl, storagePath, cachedUri, isLocalFile]);
|
||||
}, [signedUrl, cachedUri, isLocalFile, cacheVideoFile]);
|
||||
|
||||
// Toggle play/pause with native-like behavior
|
||||
const handleTapToPlay = useCallback(async () => {
|
||||
@@ -93,7 +88,7 @@ export function ChatVideoPlayer({
|
||||
cacheVideoFile();
|
||||
}
|
||||
}
|
||||
}, [isPlaying, hasEnded, signedUrl, storagePath, cachedUri]);
|
||||
}, [isPlaying, hasEnded, signedUrl, cachedUri, cacheVideoFile]);
|
||||
|
||||
// Handle full screen
|
||||
const handleFullScreen = useCallback(() => {
|
||||
@@ -180,7 +175,10 @@ export function ChatVideoPlayer({
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Wrap with React.memo for performance
|
||||
export const ChatVideoPlayer = React.memo(ChatVideoPlayerComponent);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
@@ -245,5 +243,3 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
export default ChatVideoPlayer;
|
||||
@@ -0,0 +1,133 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, TouchableOpacity, Modal, ActivityIndicator, useWindowDimensions } from 'react-native';
|
||||
import { Image } from 'expo-image';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { colors, gradients } from '../../../theme';
|
||||
|
||||
const ACCENT = colors.premium.gold;
|
||||
|
||||
interface FullScreenImageModalProps {
|
||||
uri: string | null;
|
||||
visible: boolean;
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
onLoadStart: () => void;
|
||||
onLoadEnd: () => void;
|
||||
}
|
||||
|
||||
const FullScreenImageModalComponent: React.FC<FullScreenImageModalProps> = ({
|
||||
uri,
|
||||
visible,
|
||||
loading,
|
||||
saving,
|
||||
onClose,
|
||||
onSave,
|
||||
onLoadStart,
|
||||
onLoadEnd,
|
||||
}) => {
|
||||
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
// Use safe area insets for button positioning to avoid notch/dynamic island
|
||||
const buttonTop = insets.top + 10;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
<TouchableOpacity
|
||||
style={[styles.saveButton, { top: buttonTop }]}
|
||||
onPress={onSave}
|
||||
activeOpacity={0.8}
|
||||
disabled={saving}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Save image to device"
|
||||
>
|
||||
<LinearGradient
|
||||
colors={gradients.premiumGold as [string, string]}
|
||||
style={styles.buttonGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
{saving ? (
|
||||
<ActivityIndicator size="small" color={colors.text} />
|
||||
) : (
|
||||
<Ionicons name="download-outline" size={24} color={colors.text} />
|
||||
)}
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.closeButton, { top: buttonTop }]}
|
||||
onPress={onClose}
|
||||
activeOpacity={0.8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Close full screen image"
|
||||
>
|
||||
<LinearGradient
|
||||
colors={gradients.premiumGold as [string, string]}
|
||||
style={styles.buttonGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<Ionicons name="close" size={24} color={colors.text} />
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
|
||||
{loading && (
|
||||
<ActivityIndicator size="large" color={ACCENT} style={styles.spinner} />
|
||||
)}
|
||||
|
||||
{uri && (
|
||||
<Image
|
||||
source={{ uri }}
|
||||
style={{ width: windowWidth, height: windowHeight * 0.8 }}
|
||||
contentFit="contain"
|
||||
cachePolicy="disk"
|
||||
onLoadStart={onLoadStart}
|
||||
onLoadEnd={onLoadEnd}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export const FullScreenImageModal = React.memo(FullScreenImageModalComponent);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.95)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
closeButton: {
|
||||
position: 'absolute',
|
||||
right: 20,
|
||||
zIndex: 10,
|
||||
},
|
||||
saveButton: {
|
||||
position: 'absolute',
|
||||
left: 20,
|
||||
zIndex: 10,
|
||||
},
|
||||
buttonGradient: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
spinner: {
|
||||
position: 'absolute',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, TouchableOpacity, Modal, ActivityIndicator, useWindowDimensions } from 'react-native';
|
||||
import { Video, ResizeMode } from 'expo-av';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { colors, gradients } from '../../../theme';
|
||||
|
||||
interface FullScreenVideoModalProps {
|
||||
uri: string | null;
|
||||
visible: boolean;
|
||||
saving: boolean;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
const FullScreenVideoModalComponent: React.FC<FullScreenVideoModalProps> = ({
|
||||
uri,
|
||||
visible,
|
||||
saving,
|
||||
onClose,
|
||||
onSave,
|
||||
}) => {
|
||||
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
// Use safe area insets for button positioning to avoid notch/dynamic island
|
||||
const buttonTop = insets.top + 10;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
<TouchableOpacity
|
||||
style={[styles.saveButton, { top: buttonTop }]}
|
||||
onPress={onSave}
|
||||
activeOpacity={0.8}
|
||||
disabled={saving}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Save video to device"
|
||||
>
|
||||
<LinearGradient
|
||||
colors={gradients.premiumGold as [string, string]}
|
||||
style={styles.buttonGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
{saving ? (
|
||||
<ActivityIndicator size="small" color={colors.text} />
|
||||
) : (
|
||||
<Ionicons name="download-outline" size={24} color={colors.text} />
|
||||
)}
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.closeButton, { top: buttonTop }]}
|
||||
onPress={onClose}
|
||||
activeOpacity={0.8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Close full screen video"
|
||||
>
|
||||
<LinearGradient
|
||||
colors={gradients.premiumGold as [string, string]}
|
||||
style={styles.buttonGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<Ionicons name="close" size={24} color={colors.text} />
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
|
||||
{uri && (
|
||||
<Video
|
||||
source={{ uri }}
|
||||
style={{ width: windowWidth, height: windowHeight * 0.8 }}
|
||||
resizeMode={ResizeMode.CONTAIN}
|
||||
useNativeControls
|
||||
shouldPlay={visible}
|
||||
isLooping={false}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export const FullScreenVideoModal = React.memo(FullScreenVideoModalComponent);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.95)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
closeButton: {
|
||||
position: 'absolute',
|
||||
right: 20,
|
||||
zIndex: 10,
|
||||
},
|
||||
saveButton: {
|
||||
position: 'absolute',
|
||||
left: 20,
|
||||
zIndex: 10,
|
||||
},
|
||||
buttonGradient: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
@@ -27,7 +27,7 @@ const ACCENT = colors.premium.gold;
|
||||
* Chat input bar with expandable media menu.
|
||||
* Includes text input, send button, and media action buttons.
|
||||
*/
|
||||
export function InputBar({
|
||||
const InputBarComponent: React.FC<InputBarProps> = ({
|
||||
inputText,
|
||||
uploading,
|
||||
onChangeText,
|
||||
@@ -35,7 +35,7 @@ export function InputBar({
|
||||
onPickMedia,
|
||||
onTakePhoto,
|
||||
onRecordVideo,
|
||||
}: InputBarProps) {
|
||||
}) => {
|
||||
const [menuExpanded, setMenuExpanded] = useState(false);
|
||||
const menuWidth = useSharedValue(0);
|
||||
const buttonRotation = useSharedValue(0);
|
||||
@@ -176,7 +176,10 @@ export function InputBar({
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Wrap with React.memo for performance
|
||||
export const InputBar = React.memo(InputBarComponent);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
@@ -261,5 +264,3 @@ const styles = StyleSheet.create({
|
||||
height: 20,
|
||||
},
|
||||
});
|
||||
|
||||
export default InputBar;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useRef } from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
@@ -6,24 +6,38 @@ import Animated, { FadeInDown } from 'react-native-reanimated';
|
||||
|
||||
import { DecorativeSeparator } from '../../../components/ui';
|
||||
import { colors, gradients, spacing, radius, typography } from '../../../theme';
|
||||
import { Match } from '../types';
|
||||
|
||||
// Premium color palette
|
||||
const ACCENT = colors.premium.gold;
|
||||
const ACCENT_RGBA = 'rgba(212, 175, 55, ';
|
||||
|
||||
interface MatchCardProps {
|
||||
match: any;
|
||||
match: Match | null;
|
||||
user: { id: string } | null;
|
||||
}
|
||||
|
||||
export const MatchCard: React.FC<MatchCardProps> = ({ match, user }) => {
|
||||
const MatchCardComponent: React.FC<MatchCardProps> = ({ match, user }) => {
|
||||
// Track if animation has already played to prevent re-animation on re-renders
|
||||
const hasAnimatedRef = useRef(false);
|
||||
const shouldAnimate = !hasAnimatedRef.current;
|
||||
|
||||
// Mark as animated after first render
|
||||
if (!hasAnimatedRef.current) {
|
||||
hasAnimatedRef.current = true;
|
||||
}
|
||||
if (!match?.question) return null;
|
||||
|
||||
const isYesYes = match?.match_type === 'yes_yes';
|
||||
|
||||
// Only animate on first render to prevent re-animation when list re-renders
|
||||
const enteringAnimation = shouldAnimate
|
||||
? FadeInDown.delay(100).duration(400).springify()
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(100).duration(400).springify()}
|
||||
entering={enteringAnimation}
|
||||
style={styles.matchCardContainer}
|
||||
>
|
||||
<View style={styles.matchCard}>
|
||||
@@ -66,19 +80,19 @@ export const MatchCard: React.FC<MatchCardProps> = ({ match, user }) => {
|
||||
{(() => {
|
||||
// Sort responses by created_at to determine who answered first
|
||||
const sortedResponses = [...match.responses].sort(
|
||||
(a: any, b: any) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
||||
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
||||
);
|
||||
const isTwoPart = !!match.question.partner_text;
|
||||
const isTwoPart = !!match.question?.partner_text;
|
||||
|
||||
return sortedResponses.map((response: any, index: number) => {
|
||||
return sortedResponses.map((response, index) => {
|
||||
const isUser = response.user_id === user?.id;
|
||||
const isYes = response.answer === 'yes';
|
||||
const name = isUser ? 'You' : (response.profiles?.name?.split(' ')[0] || 'Partner');
|
||||
|
||||
// First responder sees main text, second sees partner_text
|
||||
const questionText = isTwoPart && index === 1
|
||||
? match.question.partner_text
|
||||
: match.question.text;
|
||||
? match.question?.partner_text
|
||||
: match.question?.text;
|
||||
|
||||
return (
|
||||
<View key={response.user_id} style={styles.responseCard}>
|
||||
@@ -130,6 +144,9 @@ export const MatchCard: React.FC<MatchCardProps> = ({ match, user }) => {
|
||||
);
|
||||
};
|
||||
|
||||
// Wrap with React.memo for performance
|
||||
export const MatchCard = React.memo(MatchCardComponent);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
matchCardContainer: {
|
||||
marginHorizontal: spacing.md,
|
||||
|
||||
@@ -10,6 +10,9 @@ import { colors, gradients, spacing, radius } from '../../../theme';
|
||||
|
||||
const ACCENT_RGBA = 'rgba(212, 175, 55, ';
|
||||
|
||||
// Only animate the first few messages to avoid performance issues on large lists
|
||||
const MAX_ANIMATED_INDEX = 5;
|
||||
|
||||
export interface MessageBubbleProps {
|
||||
isMe: boolean;
|
||||
index: number;
|
||||
@@ -18,10 +21,18 @@ export interface MessageBubbleProps {
|
||||
onLongPress?: () => void;
|
||||
}
|
||||
|
||||
export function MessageBubble({ isMe, index, children, onLongPress }: MessageBubbleProps) {
|
||||
const MessageBubbleComponent = ({ isMe, index, children, onLongPress }: MessageBubbleProps) => {
|
||||
// Only animate recent messages (low index in inverted list) to avoid performance issues
|
||||
const shouldAnimate = index < MAX_ANIMATED_INDEX;
|
||||
const enteringAnimation = shouldAnimate ? FadeInUp.duration(200) : undefined;
|
||||
|
||||
const accessibilityLabel = isMe
|
||||
? 'Your message. Long press for options'
|
||||
: "Partner's message. Long press for options";
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
entering={FadeInUp.delay(index * 30).duration(200)}
|
||||
entering={enteringAnimation}
|
||||
style={[styles.messageRow, isMe ? styles.myMessageRow : styles.theirMessageRow]}
|
||||
>
|
||||
{isMe ? (
|
||||
@@ -30,6 +41,9 @@ export function MessageBubble({ isMe, index, children, onLongPress }: MessageBub
|
||||
activeOpacity={0.9}
|
||||
delayLongPress={300}
|
||||
style={styles.myBubbleContainer}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
accessibilityHint="Long press to delete or report"
|
||||
>
|
||||
<LinearGradient
|
||||
colors={gradients.primary as [string, string]}
|
||||
@@ -53,6 +67,9 @@ export function MessageBubble({ isMe, index, children, onLongPress }: MessageBub
|
||||
activeOpacity={0.9}
|
||||
delayLongPress={300}
|
||||
style={styles.theirBubbleContainer}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
accessibilityHint="Long press to report"
|
||||
>
|
||||
<View style={[styles.bubble, styles.theirBubble]}>
|
||||
{/* Subtle gradient background */}
|
||||
@@ -75,7 +92,10 @@ export function MessageBubble({ isMe, index, children, onLongPress }: MessageBub
|
||||
)}
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Wrap with React.memo for performance
|
||||
export const MessageBubble = React.memo(MessageBubbleComponent);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
messageRow: {
|
||||
@@ -116,5 +136,3 @@ const styles = StyleSheet.create({
|
||||
height: 30,
|
||||
},
|
||||
});
|
||||
|
||||
export default MessageBubble;
|
||||
@@ -22,18 +22,16 @@ export interface MessageContentProps {
|
||||
revealMessage: (id: string) => void;
|
||||
onImagePress: (uri: string) => void;
|
||||
onVideoFullScreen: (uri: string) => void;
|
||||
onLongPress?: () => void;
|
||||
}
|
||||
|
||||
export function MessageContent({
|
||||
const MessageContentComponent: React.FC<MessageContentProps> = ({
|
||||
item,
|
||||
isMe,
|
||||
currentUserId,
|
||||
revealMessage,
|
||||
onImagePress,
|
||||
onVideoFullScreen,
|
||||
onLongPress,
|
||||
}: MessageContentProps) {
|
||||
}) => {
|
||||
// Check if message was deleted for everyone
|
||||
if (item.deleted_at) {
|
||||
return (
|
||||
@@ -50,7 +48,7 @@ export function MessageContent({
|
||||
);
|
||||
}
|
||||
|
||||
const isVideo = (item as any).media_type === 'video';
|
||||
const isVideo = item.media_type === 'video';
|
||||
const isViewed = !!item.media_viewed_at;
|
||||
const isRecipientHidden = !isMe && !isViewed;
|
||||
|
||||
@@ -87,7 +85,7 @@ export function MessageContent({
|
||||
}, [item.media_path, isRecipientHidden]);
|
||||
|
||||
// Handle expired videos
|
||||
const isExpired = !!(item as any).media_expired;
|
||||
const isExpired = !!item.media_expired;
|
||||
|
||||
if (isExpired && isVideo) {
|
||||
return (
|
||||
@@ -240,7 +238,10 @@ export function MessageContent({
|
||||
<MessageMeta item={item} isMe={isMe} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Wrap with React.memo for performance
|
||||
export const MessageContent = React.memo(MessageContentComponent);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
deletedContainer: {
|
||||
@@ -347,5 +348,3 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: spacing.md,
|
||||
},
|
||||
});
|
||||
|
||||
export default MessageContent;
|
||||
@@ -12,7 +12,7 @@ export interface MessageMetaProps {
|
||||
/**
|
||||
* Message metadata component showing timestamp and read receipts.
|
||||
*/
|
||||
export function MessageMeta({ item, isMe }: MessageMetaProps) {
|
||||
const MessageMetaComponent: React.FC<MessageMetaProps> = ({ item, isMe }) => {
|
||||
const status = getReceiptStatus(item.delivered_at, item.read_at);
|
||||
|
||||
return (
|
||||
@@ -27,7 +27,10 @@ export function MessageMeta({ item, isMe }: MessageMetaProps) {
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Wrap with React.memo for performance
|
||||
export const MessageMeta = React.memo(MessageMetaComponent);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
@@ -43,5 +46,3 @@ const styles = StyleSheet.create({
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
});
|
||||
|
||||
export default MessageMeta;
|
||||
@@ -15,7 +15,7 @@ interface TypingDotProps {
|
||||
delay: number;
|
||||
}
|
||||
|
||||
function TypingDot({ delay }: TypingDotProps) {
|
||||
const TypingDot = React.memo(function TypingDot({ delay }: TypingDotProps) {
|
||||
const opacity = useSharedValue(0.4);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -43,13 +43,13 @@ function TypingDot({ delay }: TypingDotProps) {
|
||||
/>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Typing indicator component showing three animated dots
|
||||
* when partner is typing.
|
||||
*/
|
||||
export function TypingIndicator() {
|
||||
const TypingIndicatorComponent: React.FC = () => {
|
||||
return (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(200)}
|
||||
@@ -71,7 +71,10 @@ export function TypingIndicator() {
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Wrap with React.memo for performance
|
||||
export const TypingIndicator = React.memo(TypingIndicatorComponent);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
@@ -104,5 +107,3 @@ const styles = StyleSheet.create({
|
||||
borderRadius: 4,
|
||||
},
|
||||
});
|
||||
|
||||
export default TypingIndicator;
|
||||
@@ -16,3 +16,6 @@ export { ChatHeader } from './ChatHeader';
|
||||
export { ChatMessages } from './ChatMessages';
|
||||
export { MatchCard } from './MatchCard';
|
||||
export { ReportMessageSheet } from './ReportMessageSheet';
|
||||
export { ChatSettingsSheet } from './ChatSettingsSheet';
|
||||
export { FullScreenImageModal } from './FullScreenImageModal';
|
||||
export { FullScreenVideoModal } from './FullScreenVideoModal';
|
||||
@@ -25,6 +25,7 @@ export interface Match {
|
||||
responses?: Array<{
|
||||
user_id: string;
|
||||
answer: string;
|
||||
created_at: string;
|
||||
profiles?: {
|
||||
name?: string;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { RealtimeChannel } from '@supabase/supabase-js';
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
export interface UseTypingIndicatorConfig {
|
||||
@@ -10,6 +11,8 @@ export interface UseTypingIndicatorConfig {
|
||||
typingTimeout?: number;
|
||||
/** Whether the screen is currently focused */
|
||||
isFocused?: boolean;
|
||||
/** Throttle interval in ms for sending typing events. Default: 2000 */
|
||||
throttleInterval?: number;
|
||||
}
|
||||
|
||||
export interface UseTypingIndicatorReturn {
|
||||
@@ -22,6 +25,7 @@ export interface UseTypingIndicatorReturn {
|
||||
}
|
||||
|
||||
const DEFAULT_TYPING_TIMEOUT = 3000;
|
||||
const DEFAULT_THROTTLE_INTERVAL = 2000;
|
||||
|
||||
/**
|
||||
* Hook for managing typing indicator state and broadcast events.
|
||||
@@ -33,11 +37,19 @@ const DEFAULT_TYPING_TIMEOUT = 3000;
|
||||
export const useTypingIndicator = (
|
||||
config: UseTypingIndicatorConfig
|
||||
): UseTypingIndicatorReturn => {
|
||||
const { channelName, userId, typingTimeout = DEFAULT_TYPING_TIMEOUT, isFocused = true } = config;
|
||||
const {
|
||||
channelName,
|
||||
userId,
|
||||
typingTimeout = DEFAULT_TYPING_TIMEOUT,
|
||||
isFocused = true,
|
||||
throttleInterval = DEFAULT_THROTTLE_INTERVAL,
|
||||
} = config;
|
||||
|
||||
const [partnerTyping, setPartnerTyping] = useState(false);
|
||||
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isFocusedRef = useRef(isFocused);
|
||||
const lastTypingEventRef = useRef<number>(0);
|
||||
const channelRef = useRef<RealtimeChannel | null>(null);
|
||||
|
||||
// Update ref when focus changes and clear state if blurred
|
||||
useEffect(() => {
|
||||
@@ -65,6 +77,7 @@ export const useTypingIndicator = (
|
||||
if (!channelName || !userId) return;
|
||||
|
||||
const channel = supabase.channel(channelName);
|
||||
channelRef.current = channel;
|
||||
|
||||
channel.on('broadcast', { event: 'typing' }, (payload) => {
|
||||
// Only process events if the screen is focused
|
||||
@@ -88,6 +101,7 @@ export const useTypingIndicator = (
|
||||
channel.subscribe();
|
||||
|
||||
return () => {
|
||||
channelRef.current = null;
|
||||
supabase.removeChannel(channel);
|
||||
if (typingTimeoutRef.current) {
|
||||
clearTimeout(typingTimeoutRef.current);
|
||||
@@ -96,17 +110,22 @@ export const useTypingIndicator = (
|
||||
}, [channelName, userId, typingTimeout]);
|
||||
|
||||
/**
|
||||
* Send a typing event to the partner
|
||||
* Send a typing event to the partner (throttled to reduce network traffic)
|
||||
*/
|
||||
const sendTypingEvent = useCallback(() => {
|
||||
if (!channelName || !userId) return;
|
||||
if (!channelRef.current || !userId) return;
|
||||
|
||||
supabase.channel(channelName).send({
|
||||
// Throttle typing events to reduce network traffic
|
||||
const now = Date.now();
|
||||
if (now - lastTypingEventRef.current < throttleInterval) return;
|
||||
lastTypingEventRef.current = now;
|
||||
|
||||
channelRef.current.send({
|
||||
type: 'broadcast',
|
||||
event: 'typing',
|
||||
payload: { userId },
|
||||
});
|
||||
}, [channelName, userId]);
|
||||
}, [userId, throttleInterval]);
|
||||
|
||||
/**
|
||||
* Manually clear the typing indicator (e.g., when a message is received)
|
||||
|
||||
@@ -14,12 +14,24 @@ interface MatchState {
|
||||
page: number;
|
||||
hasMore: boolean;
|
||||
error: string | null;
|
||||
// Archive state
|
||||
archivedMatches: Match[];
|
||||
archivedMatchIds: Set<string>;
|
||||
showArchived: boolean;
|
||||
isLoadingArchived: boolean;
|
||||
// Methods
|
||||
fetchMatches: (refresh?: boolean) => Promise<void>;
|
||||
markAsSeen: (matchId: string) => Promise<void>;
|
||||
markAllAsSeen: () => Promise<void>;
|
||||
addMatch: (match: Match) => void;
|
||||
updateMatchUnreadCount: (matchId: string, delta: number) => void;
|
||||
clearMatches: () => void;
|
||||
// Archive methods
|
||||
archiveMatch: (matchId: string) => Promise<void>;
|
||||
unarchiveMatch: (matchId: string) => Promise<void>;
|
||||
fetchArchivedMatches: () => Promise<void>;
|
||||
toggleShowArchived: () => void;
|
||||
isMatchArchived: (matchId: string) => boolean;
|
||||
}
|
||||
|
||||
export const useMatchStore = create<MatchState>((set, get) => ({
|
||||
@@ -31,6 +43,11 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
||||
page: 0,
|
||||
hasMore: true,
|
||||
error: null,
|
||||
// Archive state
|
||||
archivedMatches: [],
|
||||
archivedMatchIds: new Set<string>(),
|
||||
showArchived: false,
|
||||
isLoadingArchived: false,
|
||||
|
||||
fetchMatches: async (refresh = false) => {
|
||||
const userId = useAuthStore.getState().user?.id;
|
||||
@@ -57,7 +74,18 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
||||
const from = currentPage * BATCH_SIZE;
|
||||
const to = from + BATCH_SIZE - 1;
|
||||
|
||||
// Fetch total count on refresh
|
||||
// Fetch archived match IDs for this user on refresh
|
||||
let archivedMatchIds = state.archivedMatchIds;
|
||||
if (refresh && userId) {
|
||||
const { data: archives } = await supabase
|
||||
.from("match_archives")
|
||||
.select("match_id")
|
||||
.eq("user_id", userId);
|
||||
|
||||
archivedMatchIds = new Set(archives?.map(a => a.match_id) ?? []);
|
||||
}
|
||||
|
||||
// Fetch total count on refresh (excluding archived)
|
||||
let totalCount = state.totalCount;
|
||||
if (refresh) {
|
||||
const { count, error: countError } = await supabase
|
||||
@@ -66,7 +94,8 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
||||
.eq("couple_id", coupleId);
|
||||
|
||||
if (!countError) {
|
||||
totalCount = count ?? 0;
|
||||
// Subtract archived count from total
|
||||
totalCount = (count ?? 0) - archivedMatchIds.size;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,9 +110,13 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
||||
.range(from, to);
|
||||
|
||||
if (matchError) throw matchError;
|
||||
if (!matches || matches.length === 0) {
|
||||
|
||||
// Filter out archived matches
|
||||
const nonArchivedMatches = matches?.filter(m => !archivedMatchIds.has(m.id)) ?? [];
|
||||
|
||||
if (nonArchivedMatches.length === 0) {
|
||||
if (refresh) {
|
||||
set({ matches: [], newMatchesCount: 0, totalCount: totalCount ?? 0, isLoading: false, hasMore: false });
|
||||
set({ matches: [], newMatchesCount: 0, totalCount: totalCount ?? 0, isLoading: false, hasMore: false, archivedMatchIds });
|
||||
} else {
|
||||
set({ isLoadingMore: false, hasMore: false });
|
||||
}
|
||||
@@ -91,7 +124,7 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
||||
}
|
||||
|
||||
// Fetch all responses for these questions to determine who answered first
|
||||
const questionIds = matches.map(m => m.question_id);
|
||||
const questionIds = nonArchivedMatches.map(m => m.question_id);
|
||||
let responses: any[] = [];
|
||||
if (questionIds.length > 0) {
|
||||
const { data } = await supabase
|
||||
@@ -102,7 +135,7 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
||||
}
|
||||
|
||||
// Fetch unread message counts per match
|
||||
const matchIds = matches.map(m => m.id);
|
||||
const matchIds = nonArchivedMatches.map(m => m.id);
|
||||
let unreadCounts: Record<string, number> = {};
|
||||
|
||||
if (userId && matchIds.length > 0) {
|
||||
@@ -123,7 +156,7 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
||||
});
|
||||
}
|
||||
|
||||
const data = matches.map(match => ({
|
||||
const data = nonArchivedMatches.map(match => ({
|
||||
...match,
|
||||
responses: responses.filter(r => r.question_id === match.question_id),
|
||||
unreadCount: unreadCounts[match.id] || 0
|
||||
@@ -165,7 +198,8 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
||||
isLoading: false,
|
||||
isLoadingMore: false,
|
||||
page: currentPage + 1,
|
||||
hasMore: matches.length === BATCH_SIZE
|
||||
hasMore: nonArchivedMatches.length === BATCH_SIZE,
|
||||
archivedMatchIds: refresh ? archivedMatchIds : state.archivedMatchIds,
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -222,6 +256,195 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
||||
},
|
||||
|
||||
clearMatches: () => {
|
||||
set({ matches: [], newMatchesCount: 0, totalCount: null, isLoading: false, error: null, page: 0, hasMore: true, isLoadingMore: false });
|
||||
set({
|
||||
matches: [],
|
||||
newMatchesCount: 0,
|
||||
totalCount: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
page: 0,
|
||||
hasMore: true,
|
||||
isLoadingMore: false,
|
||||
archivedMatches: [],
|
||||
archivedMatchIds: new Set<string>(),
|
||||
showArchived: false,
|
||||
isLoadingArchived: false,
|
||||
});
|
||||
},
|
||||
|
||||
// Archive methods
|
||||
archiveMatch: async (matchId: string) => {
|
||||
const userId = useAuthStore.getState().user?.id;
|
||||
if (!userId) return;
|
||||
|
||||
// Optimistically update UI
|
||||
const state = get();
|
||||
const matchToArchive = state.matches.find(m => m.id === matchId);
|
||||
|
||||
if (matchToArchive) {
|
||||
const newArchivedMatchIds = new Set(state.archivedMatchIds);
|
||||
newArchivedMatchIds.add(matchId);
|
||||
|
||||
set({
|
||||
matches: state.matches.filter(m => m.id !== matchId),
|
||||
archivedMatches: [...state.archivedMatches, matchToArchive],
|
||||
archivedMatchIds: newArchivedMatchIds,
|
||||
totalCount: state.totalCount !== null ? state.totalCount - 1 : null,
|
||||
});
|
||||
}
|
||||
|
||||
// Insert archive record
|
||||
const { error } = await supabase
|
||||
.from("match_archives")
|
||||
.insert({ match_id: matchId, user_id: userId });
|
||||
|
||||
if (error) {
|
||||
console.error("Error archiving match:", error);
|
||||
// Revert on error
|
||||
if (matchToArchive) {
|
||||
const revertArchivedIds = new Set(get().archivedMatchIds);
|
||||
revertArchivedIds.delete(matchId);
|
||||
const currentTotalCount = get().totalCount;
|
||||
set({
|
||||
matches: [matchToArchive, ...get().matches],
|
||||
archivedMatches: get().archivedMatches.filter(m => m.id !== matchId),
|
||||
archivedMatchIds: revertArchivedIds,
|
||||
totalCount: currentTotalCount !== null ? currentTotalCount + 1 : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
unarchiveMatch: async (matchId: string) => {
|
||||
const userId = useAuthStore.getState().user?.id;
|
||||
if (!userId) return;
|
||||
|
||||
// Optimistically update UI
|
||||
const state = get();
|
||||
const matchToUnarchive = state.archivedMatches.find(m => m.id === matchId);
|
||||
|
||||
if (matchToUnarchive) {
|
||||
const newArchivedMatchIds = new Set(state.archivedMatchIds);
|
||||
newArchivedMatchIds.delete(matchId);
|
||||
|
||||
set({
|
||||
archivedMatches: state.archivedMatches.filter(m => m.id !== matchId),
|
||||
matches: [matchToUnarchive, ...state.matches],
|
||||
archivedMatchIds: newArchivedMatchIds,
|
||||
totalCount: state.totalCount !== null ? state.totalCount + 1 : null,
|
||||
});
|
||||
}
|
||||
|
||||
// Delete archive record
|
||||
const { error } = await supabase
|
||||
.from("match_archives")
|
||||
.delete()
|
||||
.eq("match_id", matchId)
|
||||
.eq("user_id", userId);
|
||||
|
||||
if (error) {
|
||||
console.error("Error unarchiving match:", error);
|
||||
// Revert on error
|
||||
if (matchToUnarchive) {
|
||||
const revertArchivedIds = new Set(get().archivedMatchIds);
|
||||
revertArchivedIds.add(matchId);
|
||||
const currentTotalCount = get().totalCount;
|
||||
set({
|
||||
archivedMatches: [matchToUnarchive, ...get().archivedMatches],
|
||||
matches: get().matches.filter(m => m.id !== matchId),
|
||||
archivedMatchIds: revertArchivedIds,
|
||||
totalCount: currentTotalCount !== null ? currentTotalCount - 1 : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
fetchArchivedMatches: async () => {
|
||||
const userId = useAuthStore.getState().user?.id;
|
||||
const coupleId = useAuthStore.getState().user?.couple_id;
|
||||
|
||||
if (!coupleId || !userId) return;
|
||||
|
||||
const state = get();
|
||||
if (state.isLoadingArchived) return;
|
||||
|
||||
set({ isLoadingArchived: true });
|
||||
|
||||
try {
|
||||
// Get archived match IDs for this user
|
||||
const { data: archives, error: archiveError } = await supabase
|
||||
.from("match_archives")
|
||||
.select("match_id")
|
||||
.eq("user_id", userId);
|
||||
|
||||
if (archiveError) throw archiveError;
|
||||
|
||||
const archivedIds = archives?.map(a => a.match_id) ?? [];
|
||||
|
||||
if (archivedIds.length === 0) {
|
||||
set({ archivedMatches: [], isLoadingArchived: false });
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch the actual match data
|
||||
const { data: matches, error: matchError } = await supabase
|
||||
.from("matches")
|
||||
.select(`
|
||||
*,
|
||||
question:questions(*)
|
||||
`)
|
||||
.in("id", archivedIds)
|
||||
.eq("couple_id", coupleId)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (matchError) throw matchError;
|
||||
|
||||
// Fetch unread counts for archived matches
|
||||
const matchIds = matches?.map(m => m.id) ?? [];
|
||||
let unreadCounts: Record<string, number> = {};
|
||||
|
||||
if (matchIds.length > 0) {
|
||||
const { data: unreadMessages } = await supabase
|
||||
.from("messages")
|
||||
.select("match_id")
|
||||
.in("match_id", matchIds)
|
||||
.neq("user_id", userId)
|
||||
.is("read_at", null);
|
||||
|
||||
unreadMessages?.forEach(msg => {
|
||||
unreadCounts[msg.match_id] = (unreadCounts[msg.match_id] || 0) + 1;
|
||||
});
|
||||
}
|
||||
|
||||
const archivedMatches = (matches ?? []).map(match => ({
|
||||
...match,
|
||||
unreadCount: unreadCounts[match.id] || 0
|
||||
}));
|
||||
|
||||
set({
|
||||
archivedMatches,
|
||||
isLoadingArchived: false,
|
||||
archivedMatchIds: new Set(archivedIds),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error fetching archived matches:", err);
|
||||
set({ isLoadingArchived: false });
|
||||
}
|
||||
},
|
||||
|
||||
toggleShowArchived: () => {
|
||||
const state = get();
|
||||
const newShowArchived = !state.showArchived;
|
||||
|
||||
set({ showArchived: newShowArchived });
|
||||
|
||||
// Fetch archived matches if switching to archived view and not yet loaded
|
||||
if (newShowArchived && state.archivedMatches.length === 0) {
|
||||
get().fetchArchivedMatches();
|
||||
}
|
||||
},
|
||||
|
||||
isMatchArchived: (matchId: string) => {
|
||||
return get().archivedMatchIds.has(matchId);
|
||||
},
|
||||
}));
|
||||
@@ -100,6 +100,14 @@ export interface MatchWithQuestion extends Match {
|
||||
question: Question;
|
||||
}
|
||||
|
||||
// Match archive (per-user)
|
||||
export interface MatchArchive {
|
||||
id: string;
|
||||
match_id: string;
|
||||
user_id: string;
|
||||
archived_at: string;
|
||||
}
|
||||
|
||||
// Pack with progress info
|
||||
export interface PackProgress {
|
||||
pack: QuestionPack;
|
||||
|
||||
@@ -98,18 +98,21 @@ export type Database = {
|
||||
app_config: {
|
||||
Row: {
|
||||
answer_gap_threshold: number | null
|
||||
daily_response_limit: number | null
|
||||
id: string
|
||||
updated_at: string | null
|
||||
updated_by: string | null
|
||||
}
|
||||
Insert: {
|
||||
answer_gap_threshold?: number | null
|
||||
daily_response_limit?: number | null
|
||||
id?: string
|
||||
updated_at?: string | null
|
||||
updated_by?: string | null
|
||||
}
|
||||
Update: {
|
||||
answer_gap_threshold?: number | null
|
||||
daily_response_limit?: number | null
|
||||
id?: string
|
||||
updated_at?: string | null
|
||||
updated_by?: string | null
|
||||
@@ -373,6 +376,42 @@ export type Database = {
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
match_archives: {
|
||||
Row: {
|
||||
id: string
|
||||
match_id: string
|
||||
user_id: string
|
||||
archived_at: string | null
|
||||
}
|
||||
Insert: {
|
||||
id?: string
|
||||
match_id: string
|
||||
user_id: string
|
||||
archived_at?: string | null
|
||||
}
|
||||
Update: {
|
||||
id?: string
|
||||
match_id?: string
|
||||
user_id?: string
|
||||
archived_at?: string | null
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "match_archives_match_id_fkey"
|
||||
columns: ["match_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "matches"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "match_archives_user_id_fkey"
|
||||
columns: ["user_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "users"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
matches: {
|
||||
Row: {
|
||||
couple_id: string
|
||||
@@ -925,6 +964,16 @@ export type Database = {
|
||||
}[]
|
||||
}
|
||||
get_auth_user_couple_id: { Args: never; Returns: string }
|
||||
get_daily_response_limit_status: {
|
||||
Args: never
|
||||
Returns: {
|
||||
is_blocked: boolean
|
||||
limit_value: number
|
||||
remaining: number
|
||||
reset_at: string
|
||||
responses_today: number
|
||||
}[]
|
||||
}
|
||||
get_feature_interest_counts: {
|
||||
Args: never
|
||||
Returns: {
|
||||
|
||||
@@ -9,6 +9,174 @@ const corsHeaders = {
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||
};
|
||||
|
||||
const DEFAULT_WHITELIST = [
|
||||
"ok",
|
||||
"okay",
|
||||
"yes",
|
||||
"no",
|
||||
"maybe",
|
||||
"thanks",
|
||||
"thank you",
|
||||
"lol",
|
||||
"lmao",
|
||||
"good night",
|
||||
"goodnight",
|
||||
"good morning",
|
||||
"love you",
|
||||
];
|
||||
|
||||
const DEFAULT_KEYWORD_TRIGGERS = [
|
||||
"suicide",
|
||||
"kill myself",
|
||||
"self harm",
|
||||
"kill you",
|
||||
"hurt you",
|
||||
"rape",
|
||||
"porn",
|
||||
"nude",
|
||||
"explicit",
|
||||
"stab",
|
||||
"shoot",
|
||||
];
|
||||
|
||||
function parseBoolean(value, defaultValue = false) {
|
||||
if (value === null || value === undefined) return defaultValue;
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "number") return value !== 0;
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (["true", "1", "yes", "y", "on"].includes(normalized)) return true;
|
||||
if (["false", "0", "no", "n", "off"].includes(normalized)) return false;
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
function parseNumber(value, defaultValue) {
|
||||
if (value === null || value === undefined) return defaultValue;
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
const parsed = Number(String(value).trim());
|
||||
return Number.isFinite(parsed) ? parsed : defaultValue;
|
||||
}
|
||||
|
||||
function parseStringArray(value) {
|
||||
if (!value) return [];
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.filter((item) => typeof item === "string")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return [];
|
||||
if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed
|
||||
.filter((item) => typeof item === "string")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
} catch {
|
||||
// Fall through to delimiter parsing
|
||||
}
|
||||
}
|
||||
return trimmed
|
||||
.split(/[\n,]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeList(values) {
|
||||
return Array.from(
|
||||
new Set(
|
||||
values
|
||||
.map((value) => value.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function getHeuristicConfig(config) {
|
||||
const enabled = parseBoolean(config?.heuristics_enabled, false);
|
||||
const minTextLength = parseNumber(config?.heuristic_min_text_length, 12);
|
||||
const whitelistMaxLength = parseNumber(config?.heuristic_whitelist_max_length, 30);
|
||||
const skipIfNoAlnum = parseBoolean(config?.heuristic_skip_if_no_alnum, true);
|
||||
const skipMediaWithoutText = parseBoolean(config?.heuristic_skip_media_without_text, false);
|
||||
const recordReason = parseBoolean(config?.heuristic_record_reason, false);
|
||||
const useDefaultWhitelist = parseBoolean(config?.heuristic_use_default_whitelist, true);
|
||||
const useDefaultKeywords = parseBoolean(config?.heuristic_use_default_keywords, true);
|
||||
|
||||
const whitelist = parseStringArray(config?.heuristic_whitelist);
|
||||
const keywordTriggers = parseStringArray(config?.heuristic_keyword_triggers);
|
||||
|
||||
const normalizedWhitelist = normalizeList(
|
||||
useDefaultWhitelist ? DEFAULT_WHITELIST.concat(whitelist) : whitelist
|
||||
);
|
||||
const normalizedKeywordTriggers = normalizeList(
|
||||
useDefaultKeywords ? DEFAULT_KEYWORD_TRIGGERS.concat(keywordTriggers) : keywordTriggers
|
||||
);
|
||||
|
||||
return {
|
||||
enabled,
|
||||
minTextLength,
|
||||
whitelistMaxLength,
|
||||
skipIfNoAlnum,
|
||||
skipMediaWithoutText,
|
||||
recordReason,
|
||||
whitelist: normalizedWhitelist,
|
||||
keywordTriggers: normalizedKeywordTriggers,
|
||||
};
|
||||
}
|
||||
|
||||
function evaluateHeuristics(text, hasMedia, config) {
|
||||
if (!config.enabled) {
|
||||
return { shouldCallAi: true, reason: "disabled" };
|
||||
}
|
||||
|
||||
const normalized = (text || "").trim();
|
||||
const lower = normalized.toLowerCase();
|
||||
const hasText = normalized.length > 0;
|
||||
const hasAlnum = /[a-z0-9]/i.test(normalized);
|
||||
|
||||
if (config.keywordTriggers.length > 0) {
|
||||
const keywordHit = config.keywordTriggers.some((keyword) => lower.includes(keyword));
|
||||
if (keywordHit) {
|
||||
return { shouldCallAi: true, reason: "keyword_trigger" };
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasMedia && !hasText) {
|
||||
return { shouldCallAi: false, reason: "no_content" };
|
||||
}
|
||||
|
||||
if (!hasMedia) {
|
||||
if (config.skipIfNoAlnum && !hasAlnum) {
|
||||
return { shouldCallAi: false, reason: "low_signal" };
|
||||
}
|
||||
|
||||
if (normalized.length <= config.whitelistMaxLength) {
|
||||
const isWhitelisted = config.whitelist.includes(lower);
|
||||
if (isWhitelisted) {
|
||||
return { shouldCallAi: false, reason: "whitelist" };
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized.length < config.minTextLength) {
|
||||
return { shouldCallAi: false, reason: "short_text" };
|
||||
}
|
||||
}
|
||||
|
||||
if (hasMedia && config.skipMediaWithoutText && !hasText) {
|
||||
return { shouldCallAi: false, reason: "media_without_text" };
|
||||
}
|
||||
|
||||
return { shouldCallAi: true, reason: "default" };
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response("ok", { headers: corsHeaders });
|
||||
@@ -42,6 +210,8 @@ Deno.serve(async (req) => {
|
||||
return new Response(JSON.stringify({ message: "Classifier disabled" }), { status: 200, headers: corsHeaders });
|
||||
}
|
||||
|
||||
const heuristicConfig = getHeuristicConfig(config);
|
||||
|
||||
const apiKey = config.openrouter_api_key || Deno.env.get("OPENROUTER_API_KEY");
|
||||
if (!apiKey) {
|
||||
console.error("OpenRouter API Key missing");
|
||||
@@ -64,15 +234,22 @@ Deno.serve(async (req) => {
|
||||
let decryptedText = message.content;
|
||||
let mediaBase64: string | null = null;
|
||||
let mediaMimeType: string | null = null;
|
||||
const hasMedia = !!message.media_path && message.media_type === "image";
|
||||
const keysMetadata = message.keys_metadata as KeysMetadata | null;
|
||||
const encryptionIv = message.encryption_iv as string | null;
|
||||
let aesKey: CryptoKey | null = null;
|
||||
|
||||
if (version === 2) {
|
||||
const keysMetadata = message.keys_metadata as KeysMetadata;
|
||||
const encryptionIv = message.encryption_iv;
|
||||
|
||||
if (version === 2 && (message.encrypted_content || hasMedia)) {
|
||||
if (!keysMetadata || !encryptionIv) {
|
||||
return new Response(JSON.stringify({ error: "Missing encryption metadata" }), { status: 400, headers: corsHeaders });
|
||||
}
|
||||
}
|
||||
|
||||
const getAesKey = async () => {
|
||||
if (aesKey) return aesKey;
|
||||
if (!keysMetadata || !encryptionIv) {
|
||||
throw new Error("Missing encryption metadata");
|
||||
}
|
||||
const adminPrivateKeyJwkStr = Deno.env.get("ADMIN_PRIVATE_KEY_JWK");
|
||||
if (!adminPrivateKeyJwkStr) {
|
||||
throw new Error("ADMIN_PRIVATE_KEY_JWK not set");
|
||||
@@ -81,7 +258,7 @@ Deno.serve(async (req) => {
|
||||
const adminPrivateKey = JSON.parse(adminPrivateKeyJwkStr);
|
||||
const rawAesKey = await unwrapWithAdminKey(keysMetadata.admin_wrapped_key, adminPrivateKey);
|
||||
|
||||
const aesKey = await crypto.subtle.importKey(
|
||||
aesKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
rawAesKey,
|
||||
{ name: "AES-GCM" },
|
||||
@@ -89,45 +266,60 @@ Deno.serve(async (req) => {
|
||||
["decrypt"]
|
||||
);
|
||||
|
||||
// Decrypt Text
|
||||
if (message.encrypted_content) {
|
||||
const plaintext = await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: base64ToUint8Array(encryptionIv) },
|
||||
aesKey,
|
||||
base64ToUint8Array(message.encrypted_content)
|
||||
);
|
||||
decryptedText = new TextDecoder().decode(plaintext);
|
||||
}
|
||||
return aesKey;
|
||||
};
|
||||
|
||||
// Decrypt Media (Image only for now)
|
||||
if (message.media_path && message.media_type === "image") {
|
||||
if (version === 2 && message.encrypted_content) {
|
||||
const key = await getAesKey();
|
||||
const plaintext = await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: base64ToUint8Array(encryptionIv as string) },
|
||||
key,
|
||||
base64ToUint8Array(message.encrypted_content)
|
||||
);
|
||||
decryptedText = new TextDecoder().decode(plaintext);
|
||||
}
|
||||
|
||||
const heuristicDecision = evaluateHeuristics(decryptedText, hasMedia, heuristicConfig);
|
||||
if (heuristicConfig.enabled && !heuristicDecision.shouldCallAi) {
|
||||
console.log(`Heuristic skip for message ${messageId}: ${heuristicDecision.reason}`);
|
||||
await updateStatus(
|
||||
supabaseAdmin,
|
||||
messageId,
|
||||
"safe",
|
||||
heuristicConfig.recordReason ? heuristicDecision.reason : null,
|
||||
"Neutral"
|
||||
);
|
||||
return new Response(JSON.stringify({ status: "safe" }), { headers: corsHeaders });
|
||||
}
|
||||
|
||||
if (hasMedia) {
|
||||
if (version === 2) {
|
||||
const key = await getAesKey();
|
||||
const { data: mediaBlob, error: downloadError } = await supabaseAdmin.storage
|
||||
.from("chat-media")
|
||||
.download(normalizeChatMediaPath(message.media_path));
|
||||
.from("chat-media")
|
||||
.download(normalizeChatMediaPath(message.media_path));
|
||||
|
||||
if (!downloadError && mediaBlob) {
|
||||
const encryptedBytes = await mediaBlob.arrayBuffer();
|
||||
const decryptedBytes = await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: base64ToUint8Array(encryptionIv) },
|
||||
aesKey,
|
||||
encryptedBytes
|
||||
);
|
||||
mediaBase64 = arrayBufferToBase64(decryptedBytes);
|
||||
mediaMimeType = "image/jpeg"; // Default or infer?
|
||||
const encryptedBytes = await mediaBlob.arrayBuffer();
|
||||
const decryptedBytes = await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: base64ToUint8Array(encryptionIv as string) },
|
||||
key,
|
||||
encryptedBytes
|
||||
);
|
||||
mediaBase64 = arrayBufferToBase64(decryptedBytes);
|
||||
mediaMimeType = "image/jpeg";
|
||||
}
|
||||
} else {
|
||||
const { data: mediaBlob } = await supabaseAdmin.storage
|
||||
.from("chat-media")
|
||||
.download(normalizeChatMediaPath(message.media_path));
|
||||
|
||||
if (mediaBlob) {
|
||||
const buffer = await mediaBlob.arrayBuffer();
|
||||
mediaBase64 = arrayBufferToBase64(buffer);
|
||||
mediaMimeType = "image/jpeg";
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Version 1 (Plaintext)
|
||||
if (message.media_path && message.media_type === "image") {
|
||||
const { data: mediaBlob } = await supabaseAdmin.storage
|
||||
.from("chat-media")
|
||||
.download(normalizeChatMediaPath(message.media_path));
|
||||
if (mediaBlob) {
|
||||
const buffer = await mediaBlob.arrayBuffer();
|
||||
mediaBase64 = arrayBufferToBase64(buffer);
|
||||
mediaMimeType = "image/jpeg";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Construct LLM Payload
|
||||
|
||||
@@ -77,6 +77,67 @@ Deno.serve(async (req) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Check if this is a new response or an edit (edits bypass daily limit)
|
||||
const { data: existingResponse } = await supabase
|
||||
.from("responses")
|
||||
.select("id")
|
||||
.eq("user_id", user.id)
|
||||
.eq("question_id", question_id)
|
||||
.maybeSingle();
|
||||
|
||||
const isNewResponse = !existingResponse;
|
||||
|
||||
// Enforce daily response limit for new responses only
|
||||
if (isNewResponse) {
|
||||
// Check if user has premium access
|
||||
const { data: hasPremiumData } = await supabase.rpc("has_premium_access", {
|
||||
check_user_id: user.id,
|
||||
});
|
||||
|
||||
const hasPremium = hasPremiumData === true;
|
||||
|
||||
// If non-premium, check daily limit
|
||||
if (!hasPremium) {
|
||||
// Get daily limit from app_config
|
||||
const { data: configData } = await supabase
|
||||
.from("app_config")
|
||||
.select("daily_response_limit")
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
|
||||
const dailyLimit = configData?.daily_response_limit || 0;
|
||||
|
||||
// If daily limit is enabled (> 0), check if user has reached it
|
||||
if (dailyLimit > 0) {
|
||||
// Calculate UTC day boundaries
|
||||
const now = new Date();
|
||||
const todayStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
||||
const tomorrowStart = new Date(todayStart);
|
||||
tomorrowStart.setUTCDate(tomorrowStart.getUTCDate() + 1);
|
||||
|
||||
// Count responses created today (UTC)
|
||||
const { count: dailyCount } = await supabase
|
||||
.from("responses")
|
||||
.select("*", { count: "exact", head: true })
|
||||
.eq("user_id", user.id)
|
||||
.gte("created_at", todayStart.toISOString())
|
||||
.lt("created_at", tomorrowStart.toISOString());
|
||||
|
||||
// If over limit, reject
|
||||
if ((dailyCount || 0) >= dailyLimit) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Daily response limit reached",
|
||||
daily_limit: dailyLimit,
|
||||
reset_at: tomorrowStart.toISOString(),
|
||||
}),
|
||||
{ status: 429, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save or update response
|
||||
const { data: response, error: responseError } = await supabase
|
||||
.from("responses")
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
-- Add admin_username column to audit_logs table
|
||||
ALTER TABLE audit_logs
|
||||
ADD COLUMN admin_username TEXT;
|
||||
|
||||
-- Backfill existing audit logs with admin names from profiles
|
||||
UPDATE audit_logs
|
||||
SET admin_username = COALESCE(p.name, p.email, 'Unknown')
|
||||
FROM profiles p
|
||||
WHERE audit_logs.admin_user_id = p.id
|
||||
AND audit_logs.admin_username IS NULL;
|
||||
|
||||
-- Update the log_admin_action function to populate admin_username from profiles
|
||||
CREATE OR REPLACE FUNCTION log_admin_action(
|
||||
p_table_name TEXT,
|
||||
p_record_id UUID,
|
||||
p_action audit_action,
|
||||
p_old_values JSONB DEFAULT NULL,
|
||||
p_new_values JSONB DEFAULT NULL
|
||||
)
|
||||
RETURNS UUID
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
log_id UUID;
|
||||
admin_role_val public.admin_role;
|
||||
admin_username_val TEXT;
|
||||
changed_fields_arr TEXT[];
|
||||
BEGIN
|
||||
-- Get admin role from admin_users table
|
||||
SELECT role
|
||||
INTO admin_role_val
|
||||
FROM public.admin_users
|
||||
WHERE user_id = auth.uid();
|
||||
|
||||
IF admin_role_val IS NULL THEN
|
||||
RAISE EXCEPTION 'User is not an admin';
|
||||
END IF;
|
||||
|
||||
-- Get username from profiles table (name or email as fallback)
|
||||
SELECT COALESCE(name, email, 'Unknown')
|
||||
INTO admin_username_val
|
||||
FROM public.profiles
|
||||
WHERE id = auth.uid();
|
||||
|
||||
IF p_action = 'UPDATE' AND p_old_values IS NOT NULL AND p_new_values IS NOT NULL THEN
|
||||
SELECT ARRAY(
|
||||
SELECT key FROM jsonb_each(p_new_values)
|
||||
WHERE p_old_values->key IS DISTINCT FROM p_new_values->key
|
||||
) INTO changed_fields_arr;
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.audit_logs (
|
||||
table_name, record_id, action, old_values, new_values,
|
||||
changed_fields, admin_user_id, admin_role, admin_username
|
||||
)
|
||||
VALUES (
|
||||
p_table_name, p_record_id, p_action, p_old_values, p_new_values,
|
||||
changed_fields_arr, auth.uid(), admin_role_val, admin_username_val
|
||||
)
|
||||
RETURNING id INTO log_id;
|
||||
|
||||
RETURN log_id;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,46 @@
|
||||
-- Create match_archives table for per-user match archiving
|
||||
-- Similar pattern to message_deletions table
|
||||
CREATE TABLE match_archives (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
match_id UUID NOT NULL REFERENCES matches(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
archived_at TIMESTAMPTZ DEFAULT now(),
|
||||
UNIQUE(match_id, user_id)
|
||||
);
|
||||
|
||||
-- Index for efficient lookup when filtering matches
|
||||
CREATE INDEX idx_match_archives_user_match ON match_archives(user_id, match_id);
|
||||
|
||||
-- Enable RLS on match_archives
|
||||
ALTER TABLE match_archives ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- RLS Policies for match_archives
|
||||
|
||||
-- Users can view their own archives (needed for filtering)
|
||||
CREATE POLICY "Users can view own archives"
|
||||
ON match_archives FOR SELECT
|
||||
USING (user_id = auth.uid());
|
||||
|
||||
-- Users can insert their own archives (for matches in their couples)
|
||||
CREATE POLICY "Users can archive matches"
|
||||
ON match_archives FOR INSERT
|
||||
WITH CHECK (
|
||||
user_id = auth.uid() AND
|
||||
EXISTS (
|
||||
SELECT 1 FROM matches m
|
||||
JOIN profiles p ON p.couple_id = m.couple_id
|
||||
WHERE m.id = match_archives.match_id
|
||||
AND p.id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Users can delete their own archives (for unarchiving)
|
||||
CREATE POLICY "Users can unarchive matches"
|
||||
ON match_archives FOR DELETE
|
||||
USING (user_id = auth.uid());
|
||||
|
||||
-- Add to realtime for live updates
|
||||
ALTER PUBLICATION supabase_realtime ADD TABLE match_archives;
|
||||
|
||||
-- Documentation
|
||||
COMMENT ON TABLE match_archives IS 'Per-user match archives. Partner still sees the match. Delete record to unarchive.';
|
||||
@@ -0,0 +1,77 @@
|
||||
-- Add daily response limit for non-premium users
|
||||
-- This feature limits how many questions a non-premium user can answer per UTC day
|
||||
|
||||
-- Add daily_response_limit column to app_config
|
||||
ALTER TABLE app_config
|
||||
ADD COLUMN daily_response_limit INTEGER DEFAULT 0;
|
||||
|
||||
COMMENT ON COLUMN app_config.daily_response_limit IS 'Maximum number of questions a non-premium user can answer per UTC day. Set to 0 to disable daily limits.';
|
||||
|
||||
-- ============================================================================
|
||||
-- RPC FUNCTION: get_daily_response_limit_status
|
||||
-- Returns the current daily response limit status for the authenticated user
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION get_daily_response_limit_status()
|
||||
RETURNS TABLE(
|
||||
responses_today INTEGER,
|
||||
limit_value INTEGER,
|
||||
remaining INTEGER,
|
||||
reset_at TIMESTAMPTZ,
|
||||
is_blocked BOOLEAN
|
||||
)
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_user_id UUID;
|
||||
v_has_premium BOOLEAN;
|
||||
v_limit INTEGER;
|
||||
v_count INTEGER;
|
||||
v_today_start TIMESTAMPTZ;
|
||||
v_tomorrow_start TIMESTAMPTZ;
|
||||
BEGIN
|
||||
v_user_id := auth.uid();
|
||||
|
||||
-- Get limit from app_config
|
||||
SELECT COALESCE(ac.daily_response_limit, 0) INTO v_limit
|
||||
FROM app_config ac
|
||||
LIMIT 1;
|
||||
|
||||
-- If limit is 0, feature is disabled
|
||||
IF v_limit = 0 THEN
|
||||
RETURN QUERY SELECT 0, 0, 0, NULL::TIMESTAMPTZ, false;
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
-- Check if user has premium access (user or partner)
|
||||
v_has_premium := public.has_premium_access(v_user_id);
|
||||
|
||||
-- Premium users bypass the limit
|
||||
IF v_has_premium THEN
|
||||
RETURN QUERY SELECT 0, 0, 0, NULL::TIMESTAMPTZ, false;
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
-- Calculate UTC day boundaries
|
||||
v_today_start := date_trunc('day', NOW() AT TIME ZONE 'UTC') AT TIME ZONE 'UTC';
|
||||
v_tomorrow_start := v_today_start + INTERVAL '1 day';
|
||||
|
||||
-- Count responses created today (UTC)
|
||||
SELECT COUNT(*)::INTEGER INTO v_count
|
||||
FROM responses r
|
||||
WHERE r.user_id = v_user_id
|
||||
AND r.created_at >= v_today_start
|
||||
AND r.created_at < v_tomorrow_start;
|
||||
|
||||
RETURN QUERY SELECT
|
||||
v_count,
|
||||
v_limit,
|
||||
GREATEST(0, v_limit - v_count),
|
||||
v_tomorrow_start,
|
||||
(v_count >= v_limit);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION get_daily_response_limit_status() IS 'Returns daily response limit status for the current user. Premium users and disabled limits return is_blocked=false.';
|
||||
@@ -0,0 +1,205 @@
|
||||
-- Update get_recommended_questions to enforce daily response limit for non-premium users
|
||||
-- When daily limit is reached, return empty (old apps will show "All caught up")
|
||||
|
||||
CREATE OR REPLACE FUNCTION get_recommended_questions(target_pack_id UUID DEFAULT NULL)
|
||||
RETURNS TABLE(
|
||||
id UUID,
|
||||
text TEXT,
|
||||
partner_text TEXT,
|
||||
is_two_part BOOLEAN,
|
||||
pack_id UUID,
|
||||
intensity INTEGER,
|
||||
partner_answered BOOLEAN,
|
||||
allowed_couple_genders TEXT[],
|
||||
target_user_genders TEXT[]
|
||||
)
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_user_id UUID;
|
||||
v_couple_id UUID;
|
||||
v_partner_id UUID;
|
||||
v_user_gender TEXT;
|
||||
v_partner_gender TEXT;
|
||||
v_couple_type TEXT;
|
||||
v_has_premium BOOLEAN;
|
||||
v_gap_threshold INTEGER;
|
||||
v_gap_count INTEGER;
|
||||
v_user_max_intensity INTEGER;
|
||||
v_daily_limit INTEGER;
|
||||
v_daily_count INTEGER;
|
||||
v_today_start TIMESTAMPTZ;
|
||||
v_tomorrow_start TIMESTAMPTZ;
|
||||
BEGIN
|
||||
v_user_id := auth.uid();
|
||||
|
||||
SELECT p.couple_id, p.gender, COALESCE(p.max_intensity, 5)
|
||||
INTO v_couple_id, v_user_gender, v_user_max_intensity
|
||||
FROM public.profiles p
|
||||
WHERE p.id = v_user_id;
|
||||
|
||||
IF v_couple_id IS NULL THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
SELECT p.id, p.gender INTO v_partner_id, v_partner_gender
|
||||
FROM public.profiles p
|
||||
WHERE p.couple_id = v_couple_id AND p.id != v_user_id
|
||||
LIMIT 1;
|
||||
|
||||
-- Check premium access (used for both answer gap and daily limit)
|
||||
v_has_premium := public.has_premium_access(v_user_id);
|
||||
|
||||
-- Check daily response limit (only when not browsing a specific pack and user is non-premium)
|
||||
IF v_partner_id IS NOT NULL AND target_pack_id IS NULL AND NOT v_has_premium THEN
|
||||
-- Get daily limit from app_config
|
||||
SELECT COALESCE(ac.daily_response_limit, 0) INTO v_daily_limit
|
||||
FROM app_config ac
|
||||
LIMIT 1;
|
||||
|
||||
-- If daily limit > 0, check if user has reached it
|
||||
IF v_daily_limit > 0 THEN
|
||||
-- Calculate UTC day boundaries
|
||||
v_today_start := date_trunc('day', NOW() AT TIME ZONE 'UTC') AT TIME ZONE 'UTC';
|
||||
v_tomorrow_start := v_today_start + INTERVAL '1 day';
|
||||
|
||||
-- Count responses created today (UTC)
|
||||
SELECT COUNT(*)::INTEGER INTO v_daily_count
|
||||
FROM responses r
|
||||
WHERE r.user_id = v_user_id
|
||||
AND r.created_at >= v_today_start
|
||||
AND r.created_at < v_tomorrow_start;
|
||||
|
||||
-- If over daily limit, return empty (old apps see "All caught up!")
|
||||
IF v_daily_count >= v_daily_limit THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Check answer gap threshold (only when not browsing a specific pack)
|
||||
IF v_partner_id IS NOT NULL AND target_pack_id IS NULL THEN
|
||||
-- Get threshold from app_config
|
||||
SELECT COALESCE(ac.answer_gap_threshold, 10) INTO v_gap_threshold
|
||||
FROM app_config ac
|
||||
LIMIT 1;
|
||||
|
||||
-- If threshold > 0, check the gap
|
||||
IF v_gap_threshold > 0 THEN
|
||||
-- Count questions in ENABLED packs where user answered but partner hasn't
|
||||
WITH enabled_packs AS (
|
||||
SELECT cp.pack_id
|
||||
FROM couple_packs cp
|
||||
WHERE cp.couple_id = v_couple_id AND cp.enabled = true
|
||||
)
|
||||
SELECT COUNT(*)::integer INTO v_gap_count
|
||||
FROM responses r
|
||||
INNER JOIN questions q ON q.id = r.question_id
|
||||
WHERE r.user_id = v_user_id
|
||||
AND r.couple_id = v_couple_id
|
||||
AND EXISTS (SELECT 1 FROM enabled_packs)
|
||||
AND q.pack_id IN (SELECT ep.pack_id FROM enabled_packs ep)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM responses pr
|
||||
WHERE pr.question_id = r.question_id
|
||||
AND pr.user_id = v_partner_id
|
||||
);
|
||||
|
||||
-- If over threshold, return empty (old apps see "All caught up!")
|
||||
IF v_gap_count >= v_gap_threshold THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
IF v_user_gender IS NOT NULL AND v_partner_gender IS NOT NULL THEN
|
||||
IF v_user_gender < v_partner_gender THEN
|
||||
v_couple_type := v_user_gender || '+' || v_partner_gender;
|
||||
ELSE
|
||||
v_couple_type := v_partner_gender || '+' || v_user_gender;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN QUERY
|
||||
WITH user_config_exists AS (
|
||||
SELECT 1 FROM public.couple_packs WHERE couple_id = v_couple_id LIMIT 1
|
||||
),
|
||||
active_packs AS (
|
||||
SELECT target_pack_id AS pack_id
|
||||
WHERE target_pack_id IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM public.question_packs qp
|
||||
WHERE qp.id = target_pack_id
|
||||
AND (qp.is_premium = FALSE OR v_has_premium = TRUE)
|
||||
)
|
||||
UNION ALL
|
||||
SELECT cp.pack_id FROM public.couple_packs cp
|
||||
INNER JOIN public.question_packs qp ON qp.id = cp.pack_id
|
||||
WHERE target_pack_id IS NULL
|
||||
AND cp.couple_id = v_couple_id
|
||||
AND cp.enabled = TRUE
|
||||
AND (qp.is_premium = FALSE OR v_has_premium = TRUE)
|
||||
AND EXISTS (SELECT 1 FROM user_config_exists)
|
||||
UNION ALL
|
||||
SELECT p.id AS pack_id FROM public.question_packs p
|
||||
WHERE target_pack_id IS NULL
|
||||
AND p.is_public = TRUE
|
||||
AND (p.is_premium = FALSE OR v_has_premium = TRUE)
|
||||
AND NOT EXISTS (SELECT 1 FROM user_config_exists)
|
||||
)
|
||||
SELECT
|
||||
q.id,
|
||||
CASE
|
||||
WHEN q.partner_text IS NOT NULL AND EXISTS (
|
||||
SELECT 1 FROM public.responses r
|
||||
WHERE r.question_id = q.id
|
||||
AND r.couple_id = v_couple_id
|
||||
AND r.user_id != v_user_id
|
||||
) THEN q.partner_text
|
||||
ELSE q.text
|
||||
END as text,
|
||||
q.partner_text,
|
||||
q.partner_text IS NOT NULL as is_two_part,
|
||||
q.pack_id,
|
||||
q.intensity,
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.responses r
|
||||
WHERE r.question_id = q.id
|
||||
AND r.couple_id = v_couple_id
|
||||
AND r.user_id != v_user_id
|
||||
) AS partner_answered,
|
||||
q.allowed_couple_genders,
|
||||
q.target_user_genders
|
||||
FROM public.questions q
|
||||
WHERE q.pack_id IN (SELECT ap.pack_id FROM active_packs ap)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.responses r
|
||||
WHERE r.question_id = q.id
|
||||
AND r.user_id = v_user_id
|
||||
)
|
||||
AND (
|
||||
q.allowed_couple_genders IS NULL
|
||||
OR v_couple_type IS NULL
|
||||
OR v_couple_type = ANY(q.allowed_couple_genders)
|
||||
)
|
||||
AND (
|
||||
q.target_user_genders IS NULL
|
||||
OR v_user_gender = ANY(q.target_user_genders)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM public.responses r
|
||||
WHERE r.question_id = q.id
|
||||
AND r.couple_id = v_couple_id
|
||||
AND r.user_id != v_user_id
|
||||
)
|
||||
)
|
||||
-- Filter by user's max_intensity preference
|
||||
AND (
|
||||
q.intensity IS NULL
|
||||
OR q.intensity <= v_user_max_intensity
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION get_recommended_questions(UUID) IS 'Returns recommended questions for user filtered by their intensity preference. When daily limit or answer gap threshold is exceeded for non-premium users, returns empty.';
|
||||
@@ -130,6 +130,14 @@ export interface Message {
|
||||
category?: 'Neutral' | 'Romantic' | 'Playful' | 'Explicit' | string | null;
|
||||
}
|
||||
|
||||
// Match archive (per-user)
|
||||
export interface MatchArchive {
|
||||
id: string;
|
||||
match_id: string;
|
||||
user_id: string;
|
||||
archived_at: string;
|
||||
}
|
||||
|
||||
// Master key for admin E2EE access
|
||||
export interface MasterKey {
|
||||
id: string;
|
||||
|
||||
Reference in new issue
Block a user