Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
25d9eda
feat(profile): implement sponsor/sponsee connection system with inten…
Mnehmos Jan 18, 2026
08aad2e
feat(profile): add opt-in matching system for sponsor/sponsee connect…
Mnehmos Jan 18, 2026
9da4f6f
refactor(lib): extract shared utilities for DRY compliance
Mnehmos Jan 18, 2026
cfe3a41
fix(supabase): add revoked_at check to invite code UPDATE policy
Mnehmos Jan 18, 2026
c4eef81
fix(supabase): replace broad RLS policy with SECURITY DEFINER RPC
Mnehmos Jan 18, 2026
4a22576
fix(supabase): convert UNIQUE constraint to partial index for rematching
Mnehmos Jan 18, 2026
cd03366
fix(privacy): fetch external handles only with mutual consent
Mnehmos Jan 18, 2026
ef4dea6
fix(security): prevent RLS violation in provider match creation
Mnehmos Jan 18, 2026
f284125
perf(settings): debounce external handles DB writes
Mnehmos Jan 18, 2026
830ff06
fix(profile): fix timer stale values and clipboard error handling
Mnehmos Jan 18, 2026
35bf3ba
refactor(settings): separate togglePlatform UI and data state
Mnehmos Jan 18, 2026
4ed37a8
test(time-utils): fix flaky "exactly now" test with fake timers
Mnehmos Jan 18, 2026
e409f68
refactor(style): rename boolean props to use is/has prefix
Mnehmos Jan 18, 2026
8050e2b
refactor(imports): use @/ alias for internal imports
Mnehmos Jan 18, 2026
4e6c0c3
refactor(lib): extract shared SheetInputComponent for bottom sheets
Mnehmos Jan 18, 2026
9f1876b
style(lib): add section dividers to platform-icons.tsx
Mnehmos Jan 18, 2026
1753fc6
test(lib): add size verification to platform-icons tests
Mnehmos Jan 18, 2026
5e45874
docs(changelog): consolidate duplicate Fixed sections and add PR revi…
Mnehmos Jan 18, 2026
07e85cc
fix: address CodeRabbit review round 2 issues
Mnehmos Jan 18, 2026
abec902
refactor(profile): use ref-based mount guard in SymmetricRevealSection
Mnehmos Jan 18, 2026
c50391f
refactor(profile): rename boolean props to use is/has prefixes
Mnehmos Jan 18, 2026
51d01f2
fix: address final CodeRabbit review comments
Mnehmos Feb 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Add Program section replacing Steps tab with 5 sub-sections: Steps, Daily, Prayers, Literature, Meetings
- Add horizontal top tabs navigation within Program section
- Add sponsor/sponsee connection system with Intent & Ownership pattern
- Add `ConnectionIntent` type with `not_looking`, `seeking_sponsor`, `open_to_sponsoring`, `open_to_both` options
- Add `ConnectionIntentSelector` component for users to set their connection preferences on profile
- Add `PersistentInviteCard` component showing active invite codes with expiration timer, copy, share, regenerate, and revoke actions
- Add `SymmetricRevealSection` component for mutual consent contact sharing within relationships
- Add `ExternalHandlesSection` in Settings for storing private contact info (Discord, Telegram, WhatsApp, Signal, Phone)
- Add `external_handles` JSONB field to profiles for storing contact info privately
- Add `sponsor_reveal_consent` and `sponsee_reveal_consent` columns to relationships for symmetric reveal
- Add `revoked_at` and `intent` columns to invite_codes for better invite management
- Add `FindSupportSection` component for opt-in matching to find sponsors/sponsees based on complementary intents
- Add `connection_matches` table with bilateral acceptance pattern (both parties must accept to connect)
- Add `find_potential_matches`, `accept_match`, and `reject_match` database functions for matching workflow
- Add database migration with RLS policies for connection intent, external handles, symmetric reveal, and opt-in matching

### Changed

- Move Steps screens from `/steps` to `/program/steps`
- Update Settings toggle label from "Include 12-Step Content" to "Show 12-Step Program" with expanded description
- Extract `getTimeRemaining` and `formatTimeRemaining` to shared `lib/time-utils.ts` (DRY refactor)
- Extract `getPlatformIcon` and `getPlatformLabel` to shared `lib/platform-icons.tsx` (DRY refactor)
- Extract `SheetInputComponent` to shared `lib/sheet-input.tsx` for platform-specific bottom sheet inputs
- Improve `lib/platform-icons.tsx` organization with canonical section dividers
- Enhance platform-icons test suite with size verification tests
- Switch invite code generation to cryptographically secure `expo-crypto` (replacing `Math.random()`)
- Memoize `handleConnectionIntentChange` with `useCallback` for performance optimization
- Export `IconTheme` interface from `lib/platform-icons.tsx` for type safety
- Strengthen `platformLabels` typing with `Record<PlatformKey, string>` constraint
- Rename boolean props for consistency (myConsent → hasMyConsent, theirConsent → hasTheirConsent, disabled → isDisabled, loadingInviteCode → isLoadingInviteCode)

### Removed

### Fixed

- Fix Steps tab still showing in native tab bar when 12-step content toggle is disabled
- Fix bottom sheet text inputs not working on web by using platform-specific InputComponent pattern
- Fix UPDATE policy missing `revoked_at` check in invite_codes RLS migration
- Fix overly broad RLS policy for profile viewing via invite code, replaced with SECURITY DEFINER RPC
- Fix UNIQUE constraint preventing sponsor/sponsee rematches after disconnect, converted to partial index
- Fix external handles exposure in profile queries before consent check
- Fix RLS violation when providers create connection matches directly
- Fix external handles DB write on every keystroke, added debouncing with draft state
- Fix timer showing stale values on mount, now updates immediately when expires_at changes
- Fix clipboard copy errors not handled in PersistentInviteCard
- Fix togglePlatform UI/data state mixing in ExternalHandlesSection
- Fix flaky time-utils test by using deterministic fake timers
- Fix import aliases not using @/ prefix in RelationshipCard and SettingsContent
- Fix empty external handle values shown in SymmetricRevealSection
- Fix timer display not showing minutes when hours = 0 in FindSupportSection
- Fix potential memory leak in SymmetricRevealSection using ref-based mount guard pattern

## [1.3.0] - 2026-01-27

### Added
Expand Down
1 change: 1 addition & 0 deletions __mocks__/lucide-react-native.js
Original file line number Diff line number Diff line change
Expand Up @@ -145,4 +145,5 @@ module.exports = {
Layout: createIconMock('Layout'),
Sparkles: createIconMock('Sparkles'),
Flame: createIconMock('Flame'),
QrCode: createIconMock('QrCode'),
};
14 changes: 14 additions & 0 deletions __tests__/app/profile.keyboard-avoidance.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,20 @@ jest.mock('lucide-react-native', () => ({
ChevronLeft: () => null,
Layout: () => null,
Sparkles: () => null,
// ConnectionIntentSelector icons
Search: () => null,
Users: () => null,
UserPlus: () => null,
// PersistentInviteCard icons
Clock: () => null,
Plus: () => null,
// SymmetricRevealSection icons
Eye: () => null,
EyeOff: () => null,
MessageCircle: () => null,
Phone: () => null,
Send: () => null,
Check: () => null,
}));

// Mock useWhatsNew hook
Expand Down
74 changes: 65 additions & 9 deletions __tests__/app/profile.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,36 @@ jest.mock('@/lib/supabase', () => ({
}
if (table === 'invite_codes') {
return {
insert: jest.fn().mockResolvedValue({ error: null }),
insert: jest.fn().mockReturnValue({
select: jest.fn().mockReturnValue({
single: jest.fn().mockResolvedValue({
data: {
id: 'invite-123',
code: 'TESTCODE',
sponsor_id: 'user-123',
expires_at: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
created_at: new Date().toISOString(),
},
error: null,
}),
}),
}),
update: jest.fn().mockReturnValue({
eq: jest.fn().mockReturnValue({
eq: jest.fn().mockResolvedValue({ error: null }),
}),
}),
select: jest.fn().mockReturnValue({
eq: jest.fn().mockReturnValue({
gt: jest.fn().mockReturnValue({
is: jest.fn().mockReturnValue({
is: jest.fn().mockReturnValue({
single: jest.fn().mockResolvedValue({ data: null, error: null }),
gt: jest.fn().mockReturnValue({
order: jest.fn().mockReturnValue({
limit: jest.fn().mockReturnValue({
maybeSingle: jest.fn().mockResolvedValue({ data: null, error: null }),
}),
}),
}),
}),
}),
maybeSingle: jest.fn().mockResolvedValue({ data: null, error: null }),
Expand Down Expand Up @@ -208,6 +232,19 @@ jest.mock('lucide-react-native', () => ({
ChevronLeft: () => null,
Layout: () => null,
Sparkles: () => null,
// Icons used by ConnectionIntentSelector
Search: () => null,
Users: () => null,
UserPlus: () => null,
// Icons used by PersistentInviteCard
Clock: () => null,
// Icons used by SymmetricRevealSection
Eye: () => null,
EyeOff: () => null,
MessageCircle: () => null,
Phone: () => null,
Send: () => null,
Check: () => null,
}));

// Mock useWhatsNew hook
Expand Down Expand Up @@ -1332,7 +1369,6 @@ describe('ProfileScreen', () => {
});

it('generates invite code when pressed', async () => {
const { Alert } = jest.requireMock('react-native');
render(<ProfileScreen />);

await waitFor(() => {
Expand All @@ -1342,10 +1378,8 @@ describe('ProfileScreen', () => {
fireEvent.press(screen.getByText('Generate Invite Code'));

await waitFor(() => {
expect(Alert.alert).toHaveBeenCalledWith(
'Invite Code Generated',
expect.stringContaining('Your invite code is:'),
expect.any(Array)
expect(Toast.show).toHaveBeenCalledWith(
expect.objectContaining({ type: 'success', text1: 'New invite code generated' })
);
});
});
Expand Down Expand Up @@ -1385,7 +1419,29 @@ describe('ProfileScreen', () => {
supabase.from.mockImplementation((table: string) => {
if (table === 'invite_codes') {
return {
insert: jest.fn().mockResolvedValue({ error: new Error('Database error') }),
insert: jest.fn().mockReturnValue({
select: jest.fn().mockReturnValue({
single: jest.fn().mockResolvedValue({
data: null,
error: new Error('Database error'),
}),
}),
}),
select: jest.fn().mockReturnValue({
eq: jest.fn().mockReturnValue({
is: jest.fn().mockReturnValue({
is: jest.fn().mockReturnValue({
gt: jest.fn().mockReturnValue({
order: jest.fn().mockReturnValue({
limit: jest.fn().mockReturnValue({
maybeSingle: jest.fn().mockResolvedValue({ data: null, error: null }),
}),
}),
}),
}),
}),
}),
}),
};
}
if (table === 'sponsor_sponsee_relationships') {
Expand Down
19 changes: 19 additions & 0 deletions __tests__/app/profile.web.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,25 @@ jest.mock('lucide-react-native', () => ({
UserMinus: () => null,
CheckCircle: () => null,
Settings: () => null,
// ConnectionIntentSelector icons
Search: () => null,
Users: () => null,
UserPlus: () => null,
// PersistentInviteCard icons
Copy: () => null,
RefreshCw: () => null,
Trash2: () => null,
Clock: () => null,
Plus: () => null,
// SymmetricRevealSection icons
Eye: () => null,
EyeOff: () => null,
MessageCircle: () => null,
Phone: () => null,
Send: () => null,
Check: () => null,
Shield: () => null,
X: () => null,
}));

jest.mock('@/lib/logger', () => ({
Expand Down
4 changes: 4 additions & 0 deletions __tests__/app/settings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,10 @@ jest.mock('lucide-react-native', () => ({
RotateCcw: () => null,
Zap: () => null,
BookOpen: () => null,
MessageCircle: () => null,
Phone: () => null,
Send: () => null,
Plus: () => null,
}));

jest.mock('@react-native-community/datetimepicker', () => {
Expand Down
5 changes: 5 additions & 0 deletions __tests__/components/SettingsSheet.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,11 @@ jest.mock('lucide-react-native', () => ({
RotateCcw: () => null,
Zap: () => null,
BookOpen: () => null,
// ExternalHandlesSection icons
MessageCircle: () => null,
Phone: () => null,
Send: () => null,
Plus: () => null,
}));

jest.mock('@react-native-community/datetimepicker', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ jest.mock('lucide-react-native', () => ({
ChevronUp: () => null,
Calendar: () => null,
BookOpen: () => null,
MessageCircle: () => null,
Phone: () => null,
Send: () => null,
Plus: () => null,
}));

jest.mock('@react-native-community/datetimepicker', () => {
Expand Down
4 changes: 4 additions & 0 deletions __tests__/components/settings/SettingsContent.dev.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ jest.mock('lucide-react-native', () => ({
Bell: () => null,
Calendar: () => null,
BookOpen: () => null,
MessageCircle: () => null,
Phone: () => null,
Send: () => null,
Plus: () => null,
}));

jest.mock('@react-native-community/datetimepicker', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ jest.mock('lucide-react-native', () => ({
Bell: () => null,
Calendar: () => null,
BookOpen: () => null,
MessageCircle: () => null,
Phone: () => null,
Send: () => null,
Plus: () => null,
}));

jest.mock('@react-native-community/datetimepicker', () => {
Expand Down
93 changes: 93 additions & 0 deletions __tests__/lib/platform-icons.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import React from 'react';
import { render } from '@testing-library/react-native';
import { View } from 'react-native';
import { getPlatformIcon, getPlatformLabel, platformLabels } from '@/lib/platform-icons';

// Mock theme for testing
const mockTheme = {
primary: '#7c3aed',
info: '#3b82f6',
success: '#22c55e',
warning: '#f59e0b',
textSecondary: '#64748b',
};

describe('platform-icons', () => {
describe('platformLabels', () => {
it('contains all expected platforms', () => {
expect(platformLabels.discord).toBe('Discord');
expect(platformLabels.telegram).toBe('Telegram');
expect(platformLabels.whatsapp).toBe('WhatsApp');
expect(platformLabels.signal).toBe('Signal');
expect(platformLabels.phone).toBe('Phone');
});
});

describe('getPlatformLabel', () => {
it('returns correct label for known platforms', () => {
expect(getPlatformLabel('discord')).toBe('Discord');
expect(getPlatformLabel('telegram')).toBe('Telegram');
expect(getPlatformLabel('whatsapp')).toBe('WhatsApp');
expect(getPlatformLabel('signal')).toBe('Signal');
expect(getPlatformLabel('phone')).toBe('Phone');
});

it('returns the key itself for unknown platforms', () => {
expect(getPlatformLabel('unknown')).toBe('unknown');
expect(getPlatformLabel('custom_platform')).toBe('custom_platform');
});
});

describe('getPlatformIcon', () => {
it('returns an icon for discord', () => {
const icon = getPlatformIcon('discord', mockTheme);
const { getByTestId } = render(<View testID="icon-container">{icon}</View>);
expect(getByTestId('icon-container').children).toHaveLength(1);
});

it('returns an icon for telegram', () => {
const icon = getPlatformIcon('telegram', mockTheme);
const { getByTestId } = render(<View testID="icon-container">{icon}</View>);
expect(getByTestId('icon-container').children).toHaveLength(1);
});

it('returns an icon for whatsapp', () => {
const icon = getPlatformIcon('whatsapp', mockTheme);
const { getByTestId } = render(<View testID="icon-container">{icon}</View>);
expect(getByTestId('icon-container').children).toHaveLength(1);
});

it('returns an icon for signal', () => {
const icon = getPlatformIcon('signal', mockTheme);
const { getByTestId } = render(<View testID="icon-container">{icon}</View>);
expect(getByTestId('icon-container').children).toHaveLength(1);
});

it('returns an icon for phone', () => {
const icon = getPlatformIcon('phone', mockTheme);
const { getByTestId } = render(<View testID="icon-container">{icon}</View>);
expect(getByTestId('icon-container').children).toHaveLength(1);
});

it('returns a default icon for unknown platforms', () => {
const icon = getPlatformIcon('unknown', mockTheme);
const { getByTestId } = render(<View testID="icon-container">{icon}</View>);
expect(getByTestId('icon-container').children).toHaveLength(1);
});

it('respects custom size parameter', () => {
const icon = getPlatformIcon('discord', mockTheme, 24);
const { root } = render(<View testID="icon-container">{icon}</View>);
// Find the icon element (Svg component from lucide-react-native)
const iconElement = root.findByProps({ testID: 'icon-container' }).props.children;
expect(iconElement.props.size).toBe(24);
});

it('uses default size of 16 when not specified', () => {
const icon = getPlatformIcon('telegram', mockTheme);
const { root } = render(<View testID="icon-container">{icon}</View>);
const iconElement = root.findByProps({ testID: 'icon-container' }).props.children;
expect(iconElement.props.size).toBe(16);
});
});
});
Loading