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:
jjbruntonandClaude Opus 4.5 committed 2026-01-09 20:18:34 +00:00
1 parent 595d6bd394
commit b0f1700e40
57 files changed
+7168 -470

No files matched your search

+32
View File
@@ -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.
+112
View File
@@ -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.
+98
View File
@@ -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.
+89
View File
@@ -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
+87
View File
@@ -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.
+106
View File
@@ -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
+6 -1
View File
@@ -3,5 +3,10 @@
"allow": [
"Bash(wc:*)"
]
}
},
"enabledMcpjsonServers": [
"sauci-prod",
"sauci-non-prod"
],
"enableAllProjectMcpServers": true
}
+1
View File
@@ -3,6 +3,7 @@ node_modules/
.pnp
.pnp.js
tmp/
# Build outputs
dist/
build/
+9
View File
@@ -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
+2
View File
@@ -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
+10
View File
@@ -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;
}
+1
View File
@@ -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;
}
+305 -1
View File
@@ -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>
+39
View File
@@ -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>
);
}
+53 -137
View File
@@ -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);
});
}, [matchModalAnim]);
}, []);
const handleMatchChat = useCallback(() => {
if (matchNotification) {
const matchId = matchNotification.id;
setMatchNotification(null);
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,
+152 -4
View File
@@ -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,8 +167,20 @@ 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)}>
<SwipeableMatchItem
onArchive={handleArchive}
isArchived={showArchived}
>
<TouchableOpacity
onPress={() => router.push(`/chat/${item.id}`)}
activeOpacity={0.8}
@@ -228,6 +257,7 @@ export default function MatchesScreen() {
<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,15 +358,86 @@ 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={
showArchived ? (
<Animated.View
entering={FadeInUp.duration(600).springify()}
style={styles.emptyContent}
>
{/* 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}
@@ -372,6 +473,7 @@ export default function MatchesScreen() {
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,
+332 -13
View File
@@ -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;
}
// remaining = how many more questions until blocked
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;
}
};
// 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;
// effective total = current position + remaining (capped by actual questions)
return Math.min(questions.length, currentIndex + Math.max(0, remaining));
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,8 +6,14 @@ import { useMediaPicker } from '@/hooks/useMediaPicker';
describe('useMediaPicker', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
describe('pickMedia', () => {
it('returns null when user cancels library picker', async () => {
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValueOnce({
canceled: true,
@@ -79,6 +85,42 @@ describe('useMediaPicker', () => {
});
});
it('uses custom config options', async () => {
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValueOnce({
canceled: true,
});
const { result } = renderHook(() => useMediaPicker({
imageQuality: 0.5,
libraryVideoMaxDuration: 120,
}));
await act(async () => {
await result.current.pickMedia();
});
expect(ImagePicker.launchImageLibraryAsync).toHaveBeenCalledWith({
mediaTypes: ImagePicker.MediaTypeOptions.All,
quality: 0.5,
videoMaxDuration: 120,
});
});
});
describe('requestCameraPermission', () => {
it('returns true when permission granted', async () => {
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'granted' });
const { result } = renderHook(() => useMediaPicker());
let ok: any;
await act(async () => {
ok = await result.current.requestCameraPermission();
});
expect(ok).toBe(true);
});
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' });
@@ -91,6 +133,228 @@ describe('useMediaPicker', () => {
});
expect(ok).toBe(false);
expect(alertSpy).toHaveBeenCalled();
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,10 +22,24 @@ 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);
});
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 },
@@ -31,19 +48,438 @@ describe('matchStore', () => {
const unreadMessages = [{ match_id: 'a' }, { match_id: 'a' }];
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(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);
});
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,
},
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,10 +50,42 @@ 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 () => {
describe('initializeRevenueCat', () => {
it('skips initialization if RevenueCat not available', async () => {
(revenueCatService.isAvailable as jest.Mock).mockReturnValue(false);
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();
});
});
describe('fetchOfferings', () => {
it('stores offering on success', async () => {
await useSubscriptionStore.getState().fetchOfferings();
const state = useSubscriptionStore.getState();
@@ -52,12 +94,192 @@ describe('subscriptionStore', () => {
expect(state.error).toBeNull();
});
it('clearSubscription resets state', () => {
useSubscriptionStore.setState({ offerings: { identifier: 'x', availablePackages: [] } } as any);
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';
+103 -170
View File
@@ -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)}
loading={fullScreenImageLoading}
saving={mediaSaving}
onClose={handleCloseImage}
onSave={handleSaveImage}
onLoadStart={handleImageLoadStart}
onLoadEnd={handleImageLoadEnd}
/>
)}
</View>
</Modal>
{/* 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}
saving={mediaSaving}
onClose={handleCloseVideo}
onSave={handleSaveVideo}
/>
)}
</View>
</Modal>
{/* 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>
{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';
+1
View File
@@ -25,6 +25,7 @@ export interface Match {
responses?: Array<{
user_id: string;
answer: string;
created_at: string;
profiles?: {
name?: string;
};
+24 -5
View File
@@ -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)
+232 -9
View File
@@ -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);
},
}));
+8
View File
@@ -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;
+49
View File
@@ -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: {
+209 -17
View File
@@ -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,18 +266,35 @@ Deno.serve(async (req) => {
["decrypt"]
);
// Decrypt Text
if (message.encrypted_content) {
return aesKey;
};
if (version === 2 && message.encrypted_content) {
const key = await getAesKey();
const plaintext = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: base64ToUint8Array(encryptionIv) },
aesKey,
{ name: "AES-GCM", iv: base64ToUint8Array(encryptionIv as string) },
key,
base64ToUint8Array(message.encrypted_content)
);
decryptedText = new TextDecoder().decode(plaintext);
}
// Decrypt Media (Image only for now)
if (message.media_path && message.media_type === "image") {
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));
@@ -108,20 +302,18 @@ Deno.serve(async (req) => {
if (!downloadError && mediaBlob) {
const encryptedBytes = await mediaBlob.arrayBuffer();
const decryptedBytes = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: base64ToUint8Array(encryptionIv) },
aesKey,
{ name: "AES-GCM", iv: base64ToUint8Array(encryptionIv as string) },
key,
encryptedBytes
);
mediaBase64 = arrayBufferToBase64(decryptedBytes);
mediaMimeType = "image/jpeg"; // Default or infer?
}
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);
@@ -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.';
+8
View File
@@ -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;