perf(app): reduce redundant data fetching

Share bounded database pools across API repositories, consolidate sync and chat queries, and make mobile refreshes cache-aware and foreground-aware.

Add focused regression coverage, client freshness documentation, and repository agent role configuration.
This commit is contained in:
jjbrunton committed 2026-08-29 21:47:27 +01:00
1 parent 9957fa43f2
commit c7ee8a4851
81 files changed
+4456 -1400

No files matched your search

+8
View File
@@ -3,6 +3,14 @@ DATABASE_URL=postgresql://sauci:sauci_local_only@127.0.0.1:54320/sauci
SUPABASE_AUTH_URL=https://your-project.supabase.co
SUPABASE_AUTH_AUDIENCE=authenticated
# Optional connection-pool bounds. The API process opens one pool and shares it
# across every repository, so these are the process-wide limits, not per-domain
# ones. Raising DATABASE_POOL_MAX raises the server connections this instance can
# hold; keep the total across instances inside the database's own limit.
# DATABASE_POOL_MAX=10
# DATABASE_POOL_IDLE_TIMEOUT_MS=30000
# DATABASE_POOL_CONNECTION_TIMEOUT_MS=10000
# Server-only hosted Auth Admin credential. Used for account deletion and for
# enriching admin user lists; it is never exposed to the browser or used as a
# Supabase database client.
+9
View File
@@ -7,6 +7,9 @@ const baseSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().min(1).max(65535).default(3003),
DATABASE_URL: z.string().url(),
DATABASE_POOL_MAX: z.coerce.number().int().min(1).max(200).default(10),
DATABASE_POOL_IDLE_TIMEOUT_MS: z.coerce.number().int().min(0).max(3_600_000).default(30_000),
DATABASE_POOL_CONNECTION_TIMEOUT_MS: z.coerce.number().int().min(0).max(60_000).default(10_000),
SUPABASE_AUTH_URL: z.string().url().optional(),
SUPABASE_AUTH_ISSUER: z.string().url().optional(),
SUPABASE_AUTH_JWKS_URL: z.string().url().optional(),
@@ -28,6 +31,7 @@ export interface AppConfig {
nodeEnv: 'development' | 'test' | 'production';
port: number;
databaseUrl: string;
databasePool: { max: number; idleTimeoutMillis: number; connectionTimeoutMillis: number };
authIssuer: string;
authJwksUrl?: string;
authAudience: string;
@@ -81,6 +85,11 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
nodeEnv: parsed.NODE_ENV,
port: parsed.PORT,
databaseUrl: parsed.DATABASE_URL,
databasePool: {
max: parsed.DATABASE_POOL_MAX,
idleTimeoutMillis: parsed.DATABASE_POOL_IDLE_TIMEOUT_MS,
connectionTimeoutMillis: parsed.DATABASE_POOL_CONNECTION_TIMEOUT_MS,
},
authIssuer,
authJwksUrl,
authAudience: parsed.SUPABASE_AUTH_AUDIENCE,
+50
View File
@@ -0,0 +1,50 @@
import { Pool } from 'pg';
/**
* A repository is constructed either from a connection string, in which case it
* owns the pool it creates and must end it, or from a pool the caller owns. The
* API process takes the second path so every domain shares one bounded pool
* instead of opening a default-sized pool per repository; tests keep taking the
* first so each suite stays independently closable.
*/
export type DatabaseConnection = string | Pool;
export interface DatabasePoolOptions {
/** Server-wide ceiling on concurrent PostgreSQL connections. */
max?: number;
idleTimeoutMillis?: number;
connectionTimeoutMillis?: number;
}
/**
* `pg` defaults to ten connections per pool, which multiplied by one pool per
* repository. One shared pool of the same size keeps the same headroom for a
* single API process while bounding what a self-hosted PostgreSQL must accept.
*/
export const DEFAULT_POOL_MAX = 10;
export function createDatabasePool(databaseUrl: string, options: DatabasePoolOptions = {}): Pool {
return new Pool({
connectionString: databaseUrl,
max: options.max ?? DEFAULT_POOL_MAX,
...(options.idleTimeoutMillis === undefined ? {} : { idleTimeoutMillis: options.idleTimeoutMillis }),
...(options.connectionTimeoutMillis === undefined ? {} : { connectionTimeoutMillis: options.connectionTimeoutMillis }),
});
}
export interface ResolvedPool {
pool: Pool;
/** True when this repository created the pool and is responsible for ending it. */
owned: boolean;
}
export function resolvePool(connection: DatabaseConnection): ResolvedPool {
return typeof connection === 'string'
? { pool: new Pool({ connectionString: connection }), owned: true }
: { pool: connection, owned: false };
}
/** Ends the pool only when this repository created it, so shared pools survive. */
export async function closeResolvedPool(pool: Pool, owned: boolean): Promise<void> {
if (owned) await pool.end();
}
+21 -3
View File
@@ -2,6 +2,7 @@ import type { FeatureInterestResponse, Profile } from '@sauci/shared';
import { and, eq, sql } from 'drizzle-orm';
import { drizzle, type NodePgDatabase } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import { closeResolvedPool, resolvePool, type DatabaseConnection } from './pool.js';
import type { AuthIdentity } from '../auth.js';
import { featureInterests, profiles, type ProfileRow } from './schema.js';
@@ -44,10 +45,13 @@ function toProfile(row: ProfileRow): MobileCompatibleProfile {
export class PostgresRepository implements ApiRepository {
private readonly pool: Pool;
private readonly ownsPool: boolean;
private readonly db: NodePgDatabase;
constructor(databaseUrl: string) {
this.pool = new Pool({ connectionString: databaseUrl });
constructor(connection: DatabaseConnection) {
const resolved = resolvePool(connection);
this.pool = resolved.pool;
this.ownsPool = resolved.owned;
this.db = drizzle(this.pool);
}
@@ -55,7 +59,21 @@ export class PostgresRepository implements ApiRepository {
await this.db.select({ id: profiles.id }).from(profiles).limit(1);
}
/**
* Idempotent for the common case. `GET /v1/me` runs on every app start and on
* every session refresh, and the previous unconditional upsert made each of
* those a write that advanced `auth_synced_at` and produced dead row versions
* for autovacuum. The only fields this sync can actually change are the ones
* the conflict clause sets, so a profile that already carries the identity's
* email is returned as read and nothing is written. First-use creation and
* email enrichment keep their existing behaviour.
*/
async upsertProfile(identity: AuthIdentity): Promise<MobileCompatibleProfile> {
const [existing] = await this.db.select().from(profiles).where(eq(profiles.id, identity.id)).limit(1);
if (existing && (identity.email === null || identity.email === undefined || existing.email === identity.email)) {
return toProfile(existing);
}
const [row] = await this.db.insert(profiles).values({
id: identity.id,
name: identity.name,
@@ -93,6 +111,6 @@ export class PostgresRepository implements ApiRepository {
}
async close(): Promise<void> {
await this.pool.end();
await closeResolvedPool(this.pool, this.ownsPool);
}
}
@@ -1,4 +1,5 @@
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
import { AccountOperationError } from './types.js';
interface ProfileOperationRecord extends QueryResultRow {
@@ -63,9 +64,12 @@ async function lockCoupleAndPartner(
export class PostgresAccountOperationsRepository implements AccountOperationsRepository {
private readonly pool: Pool;
private readonly ownsPool: boolean;
constructor(databaseUrl: string) {
this.pool = new Pool({ connectionString: databaseUrl });
constructor(connection: DatabaseConnection) {
const resolved = resolvePool(connection);
this.pool = resolved.pool;
this.ownsPool = resolved.owned;
}
async deleteRelationship(userId: string): Promise<DestructiveOperationResult> {
@@ -135,7 +139,7 @@ export class PostgresAccountOperationsRepository implements AccountOperationsRep
}
async close(): Promise<void> {
await this.pool.end();
await closeResolvedPool(this.pool, this.ownsPool);
}
}
+5 -3
View File
@@ -1,4 +1,5 @@
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
import { readFile } from 'node:fs/promises';
import { resolve, sep } from 'node:path';
import { AdminError, type AdminPrincipal, type AdminQuery } from './types.js';
@@ -139,9 +140,10 @@ export interface AdminRepository {
export class PostgresAdminRepository implements AdminRepository {
private readonly pool: Pool;
constructor(databaseUrl: string, private readonly options: {
private readonly ownsPool: boolean;
constructor(connection: DatabaseConnection, private readonly options: {
adminPrivateKeyJwk?: string; mediaRoot?: string; authDirectory?: AdminAuthDirectory;
} = {}) { this.pool = new Pool({ connectionString: databaseUrl }); }
} = {}) { const resolved = resolvePool(connection); this.pool = resolved.pool; this.ownsPool = resolved.owned; }
async principal(userId: string): Promise<AdminPrincipal> {
const result = await this.pool.query<{ id: string; user_id: string; role: AdminPrincipal['role']; permissions: string[] }>(
@@ -356,5 +358,5 @@ export class PostgresAdminRepository implements AdminRepository {
await client.query('insert into audit_logs(admin_user_id,actor_user_id,table_name,action,record_id,old_values,new_values) values($1,$2,$3,$4,$5,$6,$7)', [principal.adminId, principal.userId, table, action, recordId || null, redactAudit(oldValues), redactAudit(newValues)]);
}
async close(): Promise<void> { await this.pool.end(); }
async close(): Promise<void> { await closeResolvedPool(this.pool, this.ownsPool); }
}
+20 -7
View File
@@ -1,4 +1,5 @@
import { Pool, type PoolClient } from 'pg';
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
import { AnswersError, calculateMatchType, type Answer, type MatchType, type QuestionType, type ResponseData } from './types.js';
interface ContextRow { couple_id: string | null; gender: string | null; max_intensity: number; is_premium: boolean }
@@ -44,7 +45,7 @@ export interface AnswersRepository {
submit(userId: string, input: SubmitInput): Promise<{ response: ResponseRow; match: (MatchRow & { question: QuestionRow }) | null }>;
update(userId: string, input: UpdateInput): Promise<Record<string, unknown>>;
responses(userId: string, page: number, limit: number): Promise<{ responses: unknown[]; totalCount: number }>;
matches(userId: string, page: number, limit: number, archived: boolean): Promise<{ matches: unknown[]; totalCount: number }>;
matches(userId: string, page: number, limit: number, archived: boolean): Promise<{ matches: unknown[]; totalCount: number | null; hasMore: boolean }>;
markSeen(userId: string, ids: string[]): Promise<void>;
archive(userId: string, matchId: string, archived: boolean): Promise<void>;
streak(userId: string): Promise<CoupleStreakView | null>;
@@ -53,8 +54,9 @@ export interface AnswersRepository {
export class PostgresAnswersRepository implements AnswersRepository {
private readonly pool: Pool;
private readonly ownsPool: boolean;
private zones: Promise<Set<string>> | null = null;
constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); }
constructor(connection: DatabaseConnection) { const resolved = resolvePool(connection); this.pool = resolved.pool; this.ownsPool = resolved.owned; }
private async context(client: Pool | PoolClient, userId: string): Promise<ContextRow> {
const result = await client.query<ContextRow>('select couple_id, gender, max_intensity, is_premium from profiles where id=$1', [userId]);
@@ -230,12 +232,23 @@ export class PostgresAnswersRepository implements AnswersRepository {
return {responses:rows.rows.map(this.dates),totalCount:count.rows[0]?.count??0};
}
/**
* Reading one row past the page decides `hasMore` exactly, and the total — which
* the client only records when it refreshes — is counted on the first page only
* instead of on every scroll. Unread is aggregated once per couple rather than as
* a correlated count per row: the sort key depends on it, so the old subquery
* already ran for every candidate match, not just the twenty that were returned.
*/
async matches(userId:string,page:number,limit:number,archived:boolean){
const ctx=await this.context(this.pool,userId); const offset=page*limit;
const params=[userId,ctx.couple_id,archived,limit,offset];
const base=`from matches m join questions q on q.id=m.question_id left join match_archives a on a.match_id=m.id and a.user_id=$1 where m.couple_id=$2 and (($3 and a.id is not null) or (not $3 and a.id is null))`;
const [rows,count]=await Promise.all([this.pool.query(`select m.*,json_build_object('id',q.id,'pack_id',q.pack_id,'text',q.text,'partner_text',q.partner_text,'intensity',q.intensity,'question_type',q.question_type,'config',q.config,'created_at',q.created_at) question,(select count(*)::int from messages msg where msg.match_id=m.id and msg.user_id<>$1 and msg.read_at is null) "unreadCount" ${base} order by "unreadCount" desc,m.created_at desc limit $4 offset $5`,params),this.pool.query<{count:number}>(`select count(*)::int count ${base}`,params.slice(0,3))]);
return {matches:rows.rows.map(this.dates),totalCount:count.rows[0]?.count??0};
const params=[userId,ctx.couple_id,archived,limit+1,offset];
const from=`from matches m join questions q on q.id=m.question_id left join match_archives a on a.match_id=m.id and a.user_id=$1`;
const where=`where m.couple_id=$2 and (($3 and a.id is not null) or (not $3 and a.id is null))`;
const unread=`unread as (select msg.match_id,count(*)::int c from messages msg join matches um on um.id=msg.match_id and um.couple_id=$2 where msg.user_id<>$1 and msg.read_at is null group by msg.match_id)`;
const [rows,count]=await Promise.all([
this.pool.query(`with ${unread} select m.*,json_build_object('id',q.id,'pack_id',q.pack_id,'text',q.text,'partner_text',q.partner_text,'intensity',q.intensity,'question_type',q.question_type,'config',q.config,'created_at',q.created_at) question,coalesce(u.c,0) "unreadCount" ${from} left join unread u on u.match_id=m.id ${where} order by "unreadCount" desc,m.created_at desc limit $4 offset $5`,params),
page===0?this.pool.query<{count:number}>(`select count(*)::int count ${from} ${where}`,params.slice(0,3)):null]);
return {matches:rows.rows.slice(0,limit).map(this.dates),totalCount:count?count.rows[0]?.count??0:null,hasMore:rows.rows.length>limit};
}
async markSeen(userId:string,ids:string[]){const ctx=await this.context(this.pool,userId);await this.pool.query('update matches set is_new=false where couple_id=$1 and id=any($2::uuid[])',[ctx.couple_id,ids]);}
async archive(userId:string,matchId:string,archived:boolean){const ctx=await this.context(this.pool,userId);const found=await this.pool.query('select 1 from matches where id=$1 and couple_id=$2',[matchId,ctx.couple_id]);if(!found.rowCount)throw new AnswersError('match_not_found',404,'Match was not found');if(archived)await this.pool.query('insert into match_archives(match_id,user_id) values($1,$2) on conflict do nothing',[matchId,userId]);else await this.pool.query('delete from match_archives where match_id=$1 and user_id=$2',[matchId,userId]);}
@@ -304,5 +317,5 @@ export class PostgresAnswersRepository implements AnswersRepository {
}
private async tx<T>(fn:(client:PoolClient)=>Promise<T>):Promise<T>{const c=await this.pool.connect();try{await c.query('begin');const value=await fn(c);await c.query('commit');return value;}catch(e){await c.query('rollback');throw e;}finally{c.release();}}
private dates<T extends Record<string,any>>(row:T):T{return Object.fromEntries(Object.entries(row).map(([k,v])=>[k,v instanceof Date?v.toISOString():v])) as T;}
async close(){await this.pool.end();}
async close(){await closeResolvedPool(this.pool,this.ownsPool);}
}
+129 -2
View File
@@ -1,4 +1,5 @@
import { Pool, type QueryResultRow } from 'pg';
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
export interface PackContext { id: string; name: string; icon: string | null }
export interface AppQuestion {
@@ -19,6 +20,50 @@ export interface StrokeSegment {
}
export interface LiveDrawState { strokes: StrokeSegment[]; revision: number; updated_at: string | null; updated_by: string | null }
/**
* A change summary rather than a payload. The mobile app used to re-fetch profile,
* couple, matches, both pending directions and enabled packs every five seconds to
* notice partner activity; it now compares this summary with the last one it saw and
* refreshes only the domains whose marker actually moved. Every field is a marker,
* not display data, so the response stays a few hundred bytes whatever the couple's
* history looks like.
*/
export interface SyncSummary {
server_time: string;
couple_id: string | null;
profile_updated_at: string | null;
partner_id: string | null;
partner_updated_at: string | null;
/** Matches visible to this member: archiving one of them moves the count. */
match_count: number;
new_match_count: number;
latest_match_at: string | null;
/** Partner answered, this member has not. */
pending_yours: number;
/** This member answered, partner has not. */
pending_theirs: number;
unread_total: number;
/** Digest of the enabled pack ids, so an equal-and-opposite toggle is still a change. */
enabled_packs_fingerprint: string;
/**
* Digest of each visible match's type and response summary. `match_count` /
* `new_match_count` / `latest_match_at` only move when a match is created,
* archived or unarchived — not when a partner edits an existing response and
* changes its `match_type` or `response_summary` — so this fingerprint is
* what actually notices that edit.
*/
match_state_fingerprint: string;
/**
* Digest of each visible match's unread count. `unread_total` can stay equal
* while unread messages redistribute between matches (one read, another
* receives a message), which reorders the unread-first list without moving
* the total; this fingerprint is what notices that.
*/
match_unread_fingerprint: string;
/** couple_streaks.updated_at, so a partner's answer can silently refresh an already-loaded streak. */
streak_updated_at: string | null;
}
export class AppDataError extends Error {
constructor(public readonly code: 'not_found' | 'no_couple' | 'not_recipient' | 'rate_limited' | 'revision_conflict', message: string, public readonly status: 403 | 404 | 409 | 429, public readonly details?: Record<string, unknown>) { super(message); }
}
@@ -31,6 +76,7 @@ export interface AppDataRepository {
markMediaViewed(userId: string, messageId: string, expiresAt: string | null): Promise<{ media_viewed_at: string; media_expires_at: string | null }>;
nudgeStatus(userId: string): Promise<{ last_nudge_sent_at: string | null }>;
sendNudge(userId: string): Promise<{ success: true; notification_sent: boolean; reason?: string; next_nudge_available_at: string }>;
syncSummary(userId: string): Promise<SyncSummary>;
getLiveDraw(userId: string): Promise<LiveDrawState>;
putLiveDraw(userId: string, strokes: StrokeSegment[], baseRevision: number): Promise<LiveDrawState>;
close(): Promise<void>;
@@ -38,11 +84,22 @@ export interface AppDataRepository {
interface QuestionRow extends QueryResultRow, Omit<AppQuestion, 'created_at'> { created_at: Date }
interface SyncRow extends QueryResultRow {
server_time: Date; couple_id: string | null; profile_updated_at: Date | null;
partner_id: string | null; partner_updated_at: Date | null;
match_count: number | null; new_match_count: number | null; latest_match_at: Date | null;
pending_yours: number | null; pending_theirs: number | null; unread_total: number | null;
enabled_packs_fingerprint: string | null;
match_state_fingerprint: string | null; match_unread_fingerprint: string | null;
streak_updated_at: Date | null;
}
function question(row: QuestionRow): AppQuestion { return { ...row, created_at: row.created_at.toISOString() }; }
export class PostgresAppDataRepository implements AppDataRepository {
private readonly pool: Pool;
constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); }
private readonly ownsPool: boolean;
constructor(connection: DatabaseConnection) { const resolved = resolvePool(connection); this.pool = resolved.pool; this.ownsPool = resolved.owned; }
private async visiblePack(userId: string, packId: string): Promise<PackContext> {
const result = await this.pool.query<PackContext>(`
@@ -189,5 +246,75 @@ export class PostgresAppDataRepository implements AppDataRepository {
}
return { strokes: row.strokes, revision: Number(row.revision), updated_at: row.updated_at.toISOString(), updated_by: row.updated_by };
}
async close(): Promise<void> { await this.pool.end(); }
/**
* One round trip, all scalar subqueries, nothing that grows with the couple's
* history. An unpaired member's couple-scoped markers all collapse to zero or null
* because `me.couple_id` is null, so the endpoint stays valid before pairing.
*/
async syncSummary(userId: string): Promise<SyncSummary> {
const result = await this.pool.query<SyncRow>(`
with me as (select id, couple_id, updated_at from profiles where id = $1),
visible as (
select m.id, m.is_new, m.created_at, m.match_type, m.response_summary from matches m, me
where m.couple_id = me.couple_id
and not exists (select 1 from match_archives a where a.match_id = m.id and a.user_id = me.id)
),
visible_unread as (
select v.id as match_id, count(msg.id)::int as c
from visible v
left join messages msg on msg.match_id = v.id and msg.user_id <> (select id from me)
and msg.read_at is null and msg.deleted_at is null
and not exists (select 1 from message_deletions d where d.message_id = msg.id and d.user_id = (select id from me))
group by v.id
)
select
now() as server_time,
me.couple_id,
me.updated_at as profile_updated_at,
(select p.id from profiles p where p.couple_id = me.couple_id and p.id <> me.id order by p.id limit 1) as partner_id,
(select max(p.updated_at) from profiles p where p.couple_id = me.couple_id and p.id <> me.id) as partner_updated_at,
(select count(*)::int from visible) as match_count,
(select count(*)::int from visible where is_new) as new_match_count,
(select max(created_at) from visible) as latest_match_at,
(select count(*)::int from responses r join questions q on q.id = r.question_id
where r.couple_id = me.couple_id and q.deleted_at is null and r.user_id <> me.id
and not exists (select 1 from responses o
where o.question_id = r.question_id and o.couple_id = me.couple_id and o.user_id = me.id)) as pending_yours,
(select count(*)::int from responses r join questions q on q.id = r.question_id
where r.couple_id = me.couple_id and q.deleted_at is null and r.user_id = me.id
and not exists (select 1 from responses o
where o.question_id = r.question_id and o.couple_id = me.couple_id and o.user_id <> me.id)) as pending_theirs,
(select count(*)::int from messages msg join matches ma on ma.id = msg.match_id
where ma.couple_id = me.couple_id and msg.user_id <> me.id and msg.read_at is null and msg.deleted_at is null
and not exists (select 1 from message_deletions d where d.message_id = msg.id and d.user_id = me.id)) as unread_total,
(select md5(coalesce(string_agg(cp.pack_id::text, ',' order by cp.pack_id), ''))
from couple_packs cp where cp.couple_id = me.couple_id and cp.enabled) as enabled_packs_fingerprint,
(select md5(coalesce(string_agg(v.id::text || ':' || v.match_type || ':' || coalesce(v.response_summary::text, ''), ',' order by v.id), ''))
from visible v) as match_state_fingerprint,
(select md5(coalesce(string_agg(vu.match_id::text || ':' || vu.c, ',' order by vu.match_id), ''))
from visible_unread vu) as match_unread_fingerprint,
(select cs.updated_at from couple_streaks cs where cs.couple_id = me.couple_id) as streak_updated_at
from me`, [userId]);
const row = result.rows[0];
if (!row) throw new AppDataError('not_found', 'Profile not found', 404);
return {
server_time: row.server_time.toISOString(),
couple_id: row.couple_id,
profile_updated_at: row.profile_updated_at?.toISOString() ?? null,
partner_id: row.partner_id,
partner_updated_at: row.partner_updated_at?.toISOString() ?? null,
match_count: row.match_count ?? 0,
new_match_count: row.new_match_count ?? 0,
latest_match_at: row.latest_match_at?.toISOString() ?? null,
pending_yours: row.pending_yours ?? 0,
pending_theirs: row.pending_theirs ?? 0,
unread_total: row.unread_total ?? 0,
enabled_packs_fingerprint: row.enabled_packs_fingerprint ?? '',
match_state_fingerprint: row.match_state_fingerprint ?? '',
match_unread_fingerprint: row.match_unread_fingerprint ?? '',
streak_updated_at: row.streak_updated_at?.toISOString() ?? null,
};
}
async close(): Promise<void> { await closeResolvedPool(this.pool, this.ownsPool); }
}
+5
View File
@@ -44,6 +44,11 @@ export function registerAppDataRoutes(app: App, repository: AppDataRepository):
const result = await run(() => repository.markMediaViewed(c.get('identity').id, id.data, body.data.expires_at));
return result.ok ? c.json(result.value) : c.json(error(result.cause.code, result.cause.message), result.cause.status);
});
// The client polls this instead of re-fetching every domain; see SyncSummary.
app.get('/v1/me/sync', async c => {
const result = await run(() => repository.syncSummary(c.get('identity').id));
return result.ok ? c.json(result.value) : c.json(error(result.cause.code, result.cause.message), result.cause.status);
});
app.get('/v1/me/nudge-status', async c => c.json(await repository.nudgeStatus(c.get('identity').id)));
app.post('/v1/me/nudge', async c => {
const result = await run(() => repository.sendNudge(c.get('identity').id));
+7 -3
View File
@@ -1,4 +1,5 @@
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
import { BillingError, type RedemptionResult, type RevenueCatWebhook, type SubscriptionStatus } from './types.js';
interface ProfileRecord extends QueryResultRow {
@@ -43,9 +44,12 @@ async function transaction<T>(pool: Pool, operation: (client: PoolClient) => Pro
export class PostgresBillingRepository implements BillingRepository {
private readonly pool: Pool;
private readonly ownsPool: boolean;
constructor(databaseUrl: string) {
this.pool = new Pool({ connectionString: databaseUrl });
constructor(connection: DatabaseConnection) {
const resolved = resolvePool(connection);
this.pool = resolved.pool;
this.ownsPool = resolved.owned;
}
async processRevenueCatEvent(
@@ -207,6 +211,6 @@ export class PostgresBillingRepository implements BillingRepository {
}
async close(): Promise<void> {
await this.pool.end();
await closeResolvedPool(this.pool, this.ownsPool);
}
}
+115 -21
View File
@@ -1,4 +1,5 @@
import { Pool, type QueryResultRow } from 'pg';
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
export type ChatMediaType = 'image' | 'video' | null;
export type ReportReason = 'harassment' | 'spam' | 'inappropriate_content' | 'other';
@@ -45,8 +46,37 @@ export class ChatError extends Error {
}
}
/**
* The reply to a message poll. A request without a cursor is a full snapshot; a
* request with one carries only what changed, so a quiet chat costs an empty
* array rather than the whole backlog.
*/
export interface MessagesPage {
/** Newest first. Messages created or changed since the cursor, or the snapshot. */
messages: ChatMessage[];
/** Messages the caller deleted for themselves since the cursor. */
removed_ids: string[];
/** Cursor to send as `since` on the next poll. */
server_time: string;
/** True when `messages` is a complete snapshot the client may replace its cache with. */
complete: boolean;
/** Present only when the caller asked for it, folding the typing poll into this one. */
typing?: PartnerTyping;
}
export interface PartnerTyping {
typing: boolean;
expires_at: string | null;
}
export interface ListMessagesOptions {
/** ISO timestamp from a previous `server_time`. Omit for a full snapshot. */
since?: string;
includeTyping?: boolean;
}
export interface ChatRepository {
listMessages(userId: string, matchId: string): Promise<ChatMessage[]>;
listMessages(userId: string, matchId: string, options?: ListMessagesOptions): Promise<MessagesPage>;
sendText(userId: string, matchId: string, content: string): Promise<ChatMessage>;
unreadCounts(userId: string): Promise<{ total: number; by_match: Record<string, number> }>;
markMatchRead(userId: string, matchId: string): Promise<{ updated: number; read_at: string }>;
@@ -55,13 +85,21 @@ export interface ChatRepository {
deleteForEveryone(userId: string, messageId: string): Promise<ChatMessage>;
report(userId: string, messageId: string, reason: ReportReason): Promise<void>;
setTyping(userId: string, matchId: string, ttlMs: number): Promise<void>;
getPartnerTyping(userId: string, matchId: string): Promise<{ typing: boolean; expires_at: string | null }>;
getPartnerTyping(userId: string, matchId: string): Promise<PartnerTyping>;
close(): Promise<void>;
}
// `media_expired` is derived rather than read. Nothing ever writes the stored
// column — it is inserted `false` and never updated — so a video that reached
// `media_expires_at` still arrived flagged as playable, while the media itself
// was already refused (see `accessible` in the media repository, which treats
// `media_expired or media_expires_at <= now()` as gone). Deriving it here is the
// same rule the media side already applies, and it is what makes the client's
// "Video expired" state reachable at all.
const columns = `m.id, m.match_id, m.user_id, m.content, m.created_at, m.read_at,
m.delivered_at, m.deleted_at, m.media_path, m.media_type, m.media_expires_at,
m.media_expired, m.media_viewed_at, m.version, m.encrypted_content,
(m.media_expired or (m.media_expires_at is not null and m.media_expires_at <= now())) as media_expired,
m.media_viewed_at, m.version, m.encrypted_content,
m.encryption_iv, m.keys_metadata, m.moderation_status, m.flag_reason, m.category`;
function iso(value: Date | null): string | null {
@@ -82,9 +120,12 @@ function toMessage(row: MessageRecord): ChatMessage {
export class PostgresChatRepository implements ChatRepository {
private readonly pool: Pool;
private readonly ownsPool: boolean;
constructor(databaseUrl: string) {
this.pool = new Pool({ connectionString: databaseUrl });
constructor(connection: DatabaseConnection) {
const resolved = resolvePool(connection);
this.pool = resolved.pool;
this.ownsPool = resolved.owned;
}
private async assertMatchAccess(userId: string, matchId: string): Promise<void> {
@@ -112,20 +153,68 @@ export class PostgresChatRepository implements ChatRepository {
return row;
}
async listMessages(userId: string, matchId: string): Promise<ChatMessage[]> {
/**
* Without `since` this is the previous behaviour: the newest 500 visible messages.
* With `since` it returns only rows whose visible state moved after that instant —
* new messages, existing ones whose delivery, read, deletion or media-view
* timestamp advanced, and videos whose media expired inside the window — plus the
* ids the caller has since deleted for themselves.
*
* Moderation metadata (`moderation_status`, `flag_reason`, `category`) is not
* covered: the classifier and the admin tool rewrite those columns without
* advancing any timestamp, so a delta cannot see them move. No client renders
* them today, so nothing user-visible goes stale; giving them a cursor would
* mean adding a column to `messages`.
* Rows are returned whole rather than as patches so the client merges by id and
* needs no second shape to interpret.
*
* The returned cursor is deliberately a couple of seconds behind the server clock:
* a transaction that started before this read but committed after it would
* otherwise fall into the gap between two polls. The overlap re-delivers a handful
* of rows the client already has, which its merge-by-id absorbs.
*/
async listMessages(userId: string, matchId: string, options: ListMessagesOptions = {}): Promise<MessagesPage> {
await this.assertMatchAccess(userId, matchId);
const result = await this.pool.query<MessageRecord>(
`select ${columns}
from messages m
where m.match_id = $1
and not exists (
select 1 from message_deletions d where d.message_id = m.id and d.user_id = $2
)
order by m.created_at desc, m.id desc
limit 500`,
[matchId, userId],
);
return result.rows.map(toMessage);
const since = options.since ?? null;
const visible = `not exists (select 1 from message_deletions d where d.message_id = m.id and d.user_id = $2)`;
// Expiry is the one visible change no row write announces: the clock crosses
// `media_expires_at` and nothing is updated, so no timestamp moves past the
// cursor. Comparing the deadline against the window instead emits the row in
// the single poll whose `(since, now()]` contains it, which is exactly when
// the derived `media_expired` above flips.
const changed = `(m.created_at > $3 or m.read_at > $3 or m.delivered_at > $3
or m.deleted_at > $3 or m.media_viewed_at > $3
or (m.media_expires_at > $3 and m.media_expires_at <= now()))`;
const [clock, messages, removed, typing] = await Promise.all([
this.pool.query<{ cursor: Date }>(`select now() - interval '2 seconds' as cursor`),
this.pool.query<MessageRecord>(
`select ${columns}
from messages m
where m.match_id = $1 and ${visible}${since === null ? '' : ` and ${changed}`}
order by m.created_at desc, m.id desc
limit 500`,
since === null ? [matchId, userId] : [matchId, userId, since],
),
since === null
? null
: this.pool.query<{ message_id: string }>(
`select d.message_id
from message_deletions d
join messages m on m.id = d.message_id
where m.match_id = $1 and d.user_id = $2 and d.deleted_at > $3`,
[matchId, userId, since],
),
options.includeTyping ? this.partnerTyping(userId, matchId) : null,
]);
return {
messages: messages.rows.map(toMessage),
removed_ids: removed?.rows.map((row) => row.message_id) ?? [],
server_time: clock.rows[0]!.cursor.toISOString(),
complete: since === null,
...(typing ? { typing } : {}),
};
}
async sendText(userId: string, matchId: string, content: string): Promise<ChatMessage> {
@@ -226,8 +315,8 @@ export class PostgresChatRepository implements ChatRepository {
);
}
async getPartnerTyping(userId: string, matchId: string): Promise<{ typing: boolean; expires_at: string | null }> {
await this.assertMatchAccess(userId, matchId);
/** Access already asserted by the caller, so the message poll can fold this in for free. */
private async partnerTyping(userId: string, matchId: string): Promise<PartnerTyping> {
const result = await this.pool.query<{ expires_at: Date }>(
`select expires_at from chat_typing_states
where match_id = $1 and user_id <> $2 and expires_at > now()
@@ -238,8 +327,13 @@ export class PostgresChatRepository implements ChatRepository {
return { typing: Boolean(expiresAt), expires_at: iso(expiresAt) };
}
async getPartnerTyping(userId: string, matchId: string): Promise<PartnerTyping> {
await this.assertMatchAccess(userId, matchId);
return this.partnerTyping(userId, matchId);
}
async close(): Promise<void> {
await this.pool.end();
await closeResolvedPool(this.pool, this.ownsPool);
}
}
+12 -2
View File
@@ -8,6 +8,7 @@ type AuthenticatedApp = Hono<{ Variables: { identity: AuthIdentity } }>;
const id = z.string().uuid();
const sendBody = z.object({ content: z.string().trim().min(1).max(10_000) }).strict();
const reportBody = z.object({ reason: z.enum(['harassment', 'spam', 'inappropriate_content', 'other']) }).strict();
const since = z.string().datetime({ offset: true });
function error(code: string, message: string): ApiErrorResponse {
return { error: { code, message } };
@@ -25,11 +26,20 @@ async function result<T>(operation: () => Promise<T>) {
export function registerChatRoutes(app: AuthenticatedApp, repository: ChatRepository): void {
app.get('/v1/chat/unread', async (c) => c.json(await repository.unreadCounts(c.get('identity').id)));
// `since` turns this into a delta poll and `typing=true` folds the typing check
// into the same request. Both are optional: a call with neither still answers with
// the full snapshot under the `messages` key it always used.
app.get('/v1/matches/:matchId/messages', async (c) => {
const parsedId = id.safeParse(c.req.param('matchId'));
if (!parsedId.success) return c.json(error('invalid_match_id', 'A valid match ID is required'), 400);
const response = await result(() => repository.listMessages(c.get('identity').id, parsedId.data));
return response.ok ? c.json({ messages: response.value }) : c.json(error(response.cause.code, response.cause.message), response.cause.status);
const rawSince = c.req.query('since');
const parsedSince = rawSince === undefined ? undefined : since.safeParse(rawSince);
if (parsedSince && !parsedSince.success) return c.json(error('invalid_since', 'since must be an ISO-8601 timestamp'), 400);
const response = await result(() => repository.listMessages(c.get('identity').id, parsedId.data, {
...(parsedSince?.success ? { since: parsedSince.data } : {}),
includeTyping: c.req.query('typing') === 'true',
}));
return response.ok ? c.json(response.value) : c.json(error(response.cause.code, response.cause.message), response.cause.status);
});
app.post('/v1/matches/:matchId/messages', async (c) => {
+7 -3
View File
@@ -1,5 +1,6 @@
import type { Couple, Profile } from '@sauci/shared';
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
import { CoupleError, type CoupleStateResponse } from './types.js';
interface ProfileRecord extends QueryResultRow {
@@ -93,9 +94,12 @@ async function transaction<T>(pool: Pool, work: (client: PoolClient) => Promise<
export class PostgresCoupleRepository implements CoupleRepository {
private readonly pool: Pool;
private readonly ownsPool: boolean;
constructor(databaseUrl: string) {
this.pool = new Pool({ connectionString: databaseUrl });
constructor(connection: DatabaseConnection) {
const resolved = resolvePool(connection);
this.pool = resolved.pool;
this.ownsPool = resolved.owned;
}
async getState(userId: string): Promise<CoupleStateResponse> {
@@ -199,6 +203,6 @@ export class PostgresCoupleRepository implements CoupleRepository {
}
async close(): Promise<void> {
await this.pool.end();
await closeResolvedPool(this.pool, this.ownsPool);
}
}
+7 -3
View File
@@ -1,4 +1,5 @@
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
export type DareStatus =
| 'pending' | 'active' | 'submitted' | 'completed' | 'expired' | 'declined' | 'cancelled';
@@ -181,9 +182,12 @@ function toSentDare(row: SentDareRecord, viewerId: string): SentDare {
export class PostgresDaresRepository implements DaresRepository {
private readonly pool: Pool;
private readonly ownsPool: boolean;
constructor(databaseUrl: string) {
this.pool = new Pool({ connectionString: databaseUrl });
constructor(connection: DatabaseConnection) {
const resolved = resolvePool(connection);
this.pool = resolved.pool;
this.ownsPool = resolved.owned;
}
/**
@@ -475,6 +479,6 @@ export class PostgresDaresRepository implements DaresRepository {
}
async close(): Promise<void> {
await this.pool.end();
await closeResolvedPool(this.pool, this.ownsPool);
}
}
+4 -2
View File
@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto';
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
import type { MediaKind, MediaObject, MediaUploadContext } from './types.js';
interface MediaRow extends QueryResultRow {
@@ -28,7 +29,8 @@ function object(row: MediaRow): MediaObject {
export class PostgresMediaRepository implements MediaRepository {
private readonly pool: Pool;
constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); }
private readonly ownsPool: boolean;
constructor(connection: DatabaseConnection) { const resolved = resolvePool(connection); this.pool = resolved.pool; this.ownsPool = resolved.owned; }
async create(userId: string, kind: MediaKind, storageKey: string, mimeType: string, byteSize: number, context: MediaUploadContext) {
const client = await this.pool.connect();
@@ -102,5 +104,5 @@ export class PostgresMediaRepository implements MediaRepository {
async acknowledgeDeletion(storageKey: string): Promise<void> {
await this.pool.query('delete from media_deletion_queue where storage_key=$1',[storageKey]);
}
async close() { await this.pool.end(); }
async close(): Promise<void> { await closeResolvedPool(this.pool, this.ownsPool); }
}
@@ -1,4 +1,5 @@
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
import type { ClassifierRuntimeConfig, MessageForClassification, OperationItem, ProducerSummary } from './types.js';
interface OutboxRow extends QueryResultRow {
@@ -31,7 +32,8 @@ async function transaction<T>(pool: Pool, work: (client: PoolClient) => Promise<
export class PostgresOperationsRepository implements OperationsRepository {
private readonly pool: Pool;
constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); }
private readonly ownsPool: boolean;
constructor(connection: DatabaseConnection) { const resolved = resolvePool(connection); this.pool = resolved.pool; this.ownsPool = resolved.owned; }
async produce(now: Date, limit=100): Promise<ProducerSummary> {
return transaction(this.pool, async (client) => {
@@ -312,7 +314,7 @@ export class PostgresOperationsRepository implements OperationsRepository {
async classify(id:string,status:'safe'|'flagged',reason:string|null,category:string) {
await this.pool.query('update messages set moderation_status=$2,flag_reason=$3,category=$4 where id=$1',[id,status,reason,category]);
}
async close() { await this.pool.end(); }
async close(): Promise<void> { await closeResolvedPool(this.pool, this.ownsPool); }
}
function streakCopy(days:number) {
+7 -3
View File
@@ -1,4 +1,5 @@
import { Pool } from 'pg';
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
export interface PackCategory {
id: string;
@@ -69,9 +70,12 @@ interface ProfilePreferencesRow {
export class PostgresPacksRepository implements PacksRepository {
private readonly pool: Pool;
private readonly ownsPool: boolean;
constructor(databaseUrl: string) {
this.pool = new Pool({ connectionString: databaseUrl });
constructor(connection: DatabaseConnection) {
const resolved = resolvePool(connection);
this.pool = resolved.pool;
this.ownsPool = resolved.owned;
}
private async profile(userId: string): Promise<ProfilePreferencesRow> {
@@ -215,6 +219,6 @@ export class PostgresPacksRepository implements PacksRepository {
}
async close(): Promise<void> {
await this.pool.end();
await closeResolvedPool(this.pool, this.ownsPool);
}
}
@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto';
import { Pool, type QueryResultRow } from 'pg';
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
import type {
FeedbackSubmission,
NotificationPreferenceKey,
@@ -80,9 +81,12 @@ function assertChanged(rowCount: number | null): void {
export class PostgresProfileSettingsRepository implements ProfileSettingsRepository {
private readonly pool: Pool;
private readonly ownsPool: boolean;
constructor(databaseUrl: string) {
this.pool = new Pool({ connectionString: databaseUrl });
constructor(connection: DatabaseConnection) {
const resolved = resolvePool(connection);
this.pool = resolved.pool;
this.ownsPool = resolved.owned;
}
async updateProfile(userId: string, update: ProfileUpdate): Promise<void> {
@@ -146,6 +150,6 @@ export class PostgresProfileSettingsRepository implements ProfileSettingsReposit
}
async close(): Promise<void> {
await this.pool.end();
await closeResolvedPool(this.pool, this.ownsPool);
}
}
+20 -26
View File
@@ -2,6 +2,7 @@ import { serve } from '@hono/node-server';
import { createApp } from './app.js';
import { createAuthVerifier } from './auth.js';
import { loadConfig } from './config.js';
import { createDatabasePool } from './db/pool.js';
import { PostgresRepository } from './db/repository.js';
import { PostgresAppDataRepository } from './domains/app-data/repository.js';
import { AdminRequestAuth } from './domains/admin/auth.js';
@@ -26,30 +27,34 @@ import { FilesystemMediaStorage, PostgresMediaRepository } from './domains/media
const config = loadConfig();
const authVerifier = createAuthVerifier(config);
const repository = new PostgresRepository(config.databaseUrl);
const coupleRepository = new PostgresCoupleRepository(config.databaseUrl);
const packsRepository = new PostgresPacksRepository(config.databaseUrl);
const answersRepository = new PostgresAnswersRepository(config.databaseUrl);
const chatRepository = new PostgresChatRepository(config.databaseUrl);
const daresRepository = new PostgresDaresRepository(config.databaseUrl);
const profileSettingsRepository = new PostgresProfileSettingsRepository(config.databaseUrl);
const accountOperationsRepository = new PostgresAccountOperationsRepository(config.databaseUrl);
// One deliberately bounded pool for the whole process. Repositories used to open
// a default-sized pool each, so a single API instance could hold twelve times the
// connections a self-hosted PostgreSQL was sized for.
const pool = createDatabasePool(config.databaseUrl, config.databasePool);
const repository = new PostgresRepository(pool);
const coupleRepository = new PostgresCoupleRepository(pool);
const packsRepository = new PostgresPacksRepository(pool);
const answersRepository = new PostgresAnswersRepository(pool);
const chatRepository = new PostgresChatRepository(pool);
const daresRepository = new PostgresDaresRepository(pool);
const profileSettingsRepository = new PostgresProfileSettingsRepository(pool);
const accountOperationsRepository = new PostgresAccountOperationsRepository(pool);
const accountOperationsService = new AccountOperationsService(
accountOperationsRepository,
new SupabaseAuthAdminClient(config.authIssuer, config.supabaseAuthServiceRoleKey),
new HttpRevenueCatClient(config.revenueCatApiKey, config.revenueCatEntitlementId),
new ExpoPartnerNotifier(),
);
const mediaRepository = new PostgresMediaRepository(config.databaseUrl);
const mediaRepository = new PostgresMediaRepository(pool);
const mediaStorage = new FilesystemMediaStorage(
config.mediaRoot,
config.mediaSigningSecret,
config.mediaPublicBaseUrl,
);
const appDataRepository = new PostgresAppDataRepository(config.databaseUrl);
const billingRepository = new PostgresBillingRepository(config.databaseUrl);
const appDataRepository = new PostgresAppDataRepository(pool);
const billingRepository = new PostgresBillingRepository(pool);
const billingService = new BillingService(billingRepository, config.revenueCatWebhookSecret);
const adminRepository = new PostgresAdminRepository(config.databaseUrl, {
const adminRepository = new PostgresAdminRepository(pool, {
adminPrivateKeyJwk: config.adminPrivateKeyJwk,
mediaRoot: config.mediaRoot,
authDirectory: new SupabaseAdminAuthDirectory(config.authIssuer, config.supabaseAuthServiceRoleKey),
@@ -94,20 +99,9 @@ async function shutdown(signal: string): Promise<void> {
server.close(async () => {
try {
await Promise.all([
repository.close(),
coupleRepository.close(),
packsRepository.close(),
answersRepository.close(),
chatRepository.close(),
daresRepository.close(),
profileSettingsRepository.close(),
accountOperationsRepository.close(),
mediaRepository.close(),
appDataRepository.close(),
billingRepository.close(),
adminRepository.close(),
]);
// Repositories share the process pool and no longer own a connection each,
// so ending the pool once is the whole of the database shutdown.
await pool.end();
process.exit(0);
} catch (error) {
console.error('Failed to close database connections', error);
+66 -1
View File
@@ -6,7 +6,7 @@ import { PostgresAnswersRepository } from '../src/domains/answers/repository.js'
const url=process.env.DATABASE_URL;const local=url?['127.0.0.1','localhost','::1'].includes(new URL(url).hostname):false;if(url&&!local)throw new Error('Answer integration tests only permit localhost');
describe.skipIf(!url||!local)('answers + PostgreSQL',()=>{const admin=new Pool({connectionString:url});const schema=`answers_${randomUUID().replaceAll('-','')}`;let pool:Pool;let repo:PostgresAnswersRepository;
const u1='11111111-1111-4111-8111-111111111111',u2='22222222-2222-4222-8222-222222222222',outsider='33333333-3333-4333-8333-333333333333',couple='44444444-4444-4444-8444-444444444444',other='55555555-5555-4555-8555-555555555555',pack='66666666-6666-4666-8666-666666666666',q='77777777-7777-4777-8777-777777777777',concurrentQ='88888888-8888-4888-8888-888888888888',femaleOnlyQ='99999999-9999-4999-8999-999999999999',limitQ1='aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',limitQ2='bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',catchUpQ='cccccccc-cccc-4ccc-8ccc-cccccccccccc';
beforeAll(async()=>{await admin.query(`create schema "${schema}"`);const isolated=new URL(url!);isolated.searchParams.set('options',`-c search_path=${schema}`);pool=new Pool({connectionString:isolated.toString()});repo=new PostgresAnswersRepository(isolated.toString());for(const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0014_daily_limit_local_reset.sql','0015_daily_limit_response_index.sql','0016_couple_streak_locality.sql']){const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const s of sql.split('--> statement-breakpoint'))if(s.trim())await pool.query(s);}await pool.query('insert into couples(id,invite_code) values($1,$2),($3,$4)',[couple,'ANSWERS1',other,'ANSWERS2']);await pool.query("insert into profiles(id,couple_id,gender,max_intensity) values($1,$4,'female',5),($2,$4,'male',3),($3,$5,'male',5)",[u1,u2,outsider,couple,other]);await pool.query("insert into question_packs(id,name) values($1,'Core')",[pack]);await pool.query("insert into questions(id,pack_id,text,intensity,target_user_genders) values($1,$2,'Question',3,array['female']),($3,$2,'Concurrent',2,null),($4,$2,'Female first',2,array['female']),($5,$2,'Limit one',2,null),($6,$2,'Limit two',2,null),($7,$2,'Catch up',2,null)",[q,pack,concurrentQ,femaleOnlyQ,limitQ1,limitQ2,catchUpQ]);await pool.query('insert into couple_packs(couple_id,pack_id,enabled) values($1,$2,true)',[couple,pack]);});afterAll(async()=>{await repo.close();await pool.end();await admin.query(`drop schema "${schema}" cascade`);await admin.end();});
beforeAll(async()=>{await admin.query(`create schema "${schema}"`);const isolated=new URL(url!);isolated.searchParams.set('options',`-c search_path=${schema}`);pool=new Pool({connectionString:isolated.toString()});repo=new PostgresAnswersRepository(isolated.toString());for(const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0004_chat.sql','0014_daily_limit_local_reset.sql','0015_daily_limit_response_index.sql','0016_couple_streak_locality.sql']){const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const s of sql.split('--> statement-breakpoint'))if(s.trim())await pool.query(s);}await pool.query('insert into couples(id,invite_code) values($1,$2),($3,$4)',[couple,'ANSWERS1',other,'ANSWERS2']);await pool.query("insert into profiles(id,couple_id,gender,max_intensity) values($1,$4,'female',5),($2,$4,'male',3),($3,$5,'male',5)",[u1,u2,outsider,couple,other]);await pool.query("insert into question_packs(id,name) values($1,'Core')",[pack]);await pool.query("insert into questions(id,pack_id,text,intensity,target_user_genders) values($1,$2,'Question',3,array['female']),($3,$2,'Concurrent',2,null),($4,$2,'Female first',2,array['female']),($5,$2,'Limit one',2,null),($6,$2,'Limit two',2,null),($7,$2,'Catch up',2,null)",[q,pack,concurrentQ,femaleOnlyQ,limitQ1,limitQ2,catchUpQ]);await pool.query('insert into couple_packs(couple_id,pack_id,enabled) values($1,$2,true)',[couple,pack]);});afterAll(async()=>{await repo.close();await pool.end();await admin.query(`drop schema "${schema}" cascade`);await admin.end();});
it('targets recommendation by gender and excludes completed responses',async()=>{expect((await repo.recommended(u1)).map(x=>x.id)).toContain(q);expect((await repo.recommended(u2)).map(x=>x.id)).not.toContain(q);await repo.submit(u1,{questionId:q,answer:'yes'});expect((await repo.recommended(u1)).map(x=>x.id)).not.toContain(q);});
it('rejects direct answers that bypass target-gender eligibility',async()=>{await expect(repo.submit(u2,{questionId:femaleOnlyQ,answer:'yes'})).rejects.toMatchObject({code:'question_not_eligible'});});
it('serializes concurrent partner submissions and still creates one match',async()=>{await Promise.all([repo.submit(u1,{questionId:concurrentQ,answer:'yes'}),repo.submit(u2,{questionId:concurrentQ,answer:'yes'})]);const counts=await pool.query('select count(*)::int count from matches where question_id=$1',[concurrentQ]);expect(counts.rows[0].count).toBe(1);});
@@ -18,6 +18,71 @@ it('answers the streak from the caller side rather than by stored position',asyn
expect(await repo.streak(u1)).toMatchObject({you_answered_today:true,partner_answered_today:false,partner_name:'Alex',current_streak:0});
expect(await repo.streak(u2)).toMatchObject({you_answered_today:false,partner_answered_today:true,partner_name:'Sam',current_streak:0});});
describe('match pagination',()=>{
const paged=Array.from({length:5},(_,index)=>`d0000000-0000-4000-8000-00000000000${index}`);
const pagedQuestions=Array.from({length:5},(_,index)=>`f0000000-0000-4000-8000-00000000000${index}`);
const otherMatch='e0000000-0000-4000-8000-000000000000';
beforeAll(async()=>{
await pool.query('delete from match_archives');await pool.query('delete from messages');await pool.query('delete from matches');
// One question per match: a couple may only match a given question once.
for(const[index,id]of pagedQuestions.entries())await pool.query("insert into questions(id,pack_id,text,intensity) values($1,$2,$3,2)",[id,pack,`Paged ${index}`]);
// Distinct creation times so the ordering assertion is about the query, not tie-breaking.
for(const[index,id]of paged.entries())await pool.query("insert into matches(id,couple_id,question_id,match_type,created_at) values($1,$2,$3,'yes_yes',now()-($4::text||' minutes')::interval)",[id,couple,pagedQuestions[index],String(index)]);
// A match belonging to another couple must never appear, whatever the page.
await pool.query("insert into matches(id,couple_id,question_id,match_type) values($1,$2,$3,'yes_yes')",[otherMatch,other,q]);
// Two unread from the partner on the oldest match; one message of our own, which is never unread.
await pool.query("insert into messages(match_id,user_id,content) values($1,$2,'a'),($1,$2,'b'),($1,$3,'mine')",[paged[4],u2,u1]);
});
it('counts once on the first page, never again, and still reports hasMore',async()=>{
const first=await repo.matches(u1,0,2,false);
expect(first.totalCount).toBe(5);
expect(first.hasMore).toBe(true);
expect(first.matches).toHaveLength(2);
// Later pages skip the count entirely: the client already has it and paging
// is decided by hasMore, which comes from reading limit + 1 rows.
const second=await repo.matches(u1,1,2,false);
expect(second.totalCount).toBeNull();
expect(second.hasMore).toBe(true);
const last=await repo.matches(u1,2,2,false);
expect(last.totalCount).toBeNull();
expect(last.hasMore).toBe(false);
expect(last.matches).toHaveLength(1);
// An exactly-full final page must not claim another page exists.
expect(await repo.matches(u1,0,5,false)).toMatchObject({hasMore:false,totalCount:5});
});
it('keeps unread-first ordering, aggregates unread once, and isolates the other couple',async()=>{
const page=await repo.matches(u1,0,5,false);
// The unread match sorts ahead of newer ones; the rest stay newest-first.
expect(page.matches.map(item=>item.id)).toEqual([paged[4],paged[0],paged[1],paged[2],paged[3]]);
expect(page.matches[0]).toMatchObject({unreadCount:2});
// Our own message is not unread to us, and everything else is zero.
expect(page.matches.slice(1).every(item=>item.unreadCount===0)).toBe(true);
// Unread is per viewer: the partner's own two messages are not unread to them,
// but ours is, so the same thread reports a different count on each side.
expect((await repo.matches(u2,0,5,false)).matches.find(item=>item.id===paged[4])?.unreadCount).toBe(1);
// The other couple's match is invisible here, and their member sees only theirs.
expect(page.matches.some(item=>item.id===otherMatch)).toBe(false);
expect((await repo.matches(outsider,0,5,false)).matches.map(item=>item.id)).toEqual([otherMatch]);
});
it('pages the archived view separately and per user',async()=>{
await repo.archive(u1,paged[0],true);
const active=await repo.matches(u1,0,5,false);
expect(active.matches.some(item=>item.id===paged[0])).toBe(false);
expect(active.totalCount).toBe(4);
const archived=await repo.matches(u1,0,5,true);
expect(archived.matches.map(item=>item.id)).toEqual([paged[0]]);
expect(archived).toMatchObject({totalCount:1,hasMore:false});
// Archiving is per user, so the partner still sees it in their active list.
expect((await repo.matches(u2,0,5,false)).matches.some(item=>item.id===paged[0])).toBe(true);
await repo.archive(u1,paged[0],false);
});
});
it('counts the day in the couple shared zone and prefers the first reported member zone',async()=>{await pool.query('update profiles set timezone=$2 where id=$1',[u1,'Pacific/Auckland']);await pool.query('update profiles set timezone=$2 where id=$1',[u2,'America/Los_Angeles']);
await pool.query('delete from responses where couple_id=$1',[couple]);await pool.query('delete from matches where couple_id=$1',[couple]);
await repo.submit(u1,{questionId:q,answer:'yes'});
+11 -1
View File
@@ -10,7 +10,7 @@ const questionId='22222222-2222-4222-8222-222222222222';
function repo():AnswersRepository{return {
recommended:vi.fn(async()=>[]),pending:vi.fn(async()=>[]),answerGap:vi.fn(async()=>({unanswered_by_partner:0,threshold:10,is_blocked:false})),
dailyLimit:vi.fn(async()=>({responses_today:0,limit_value:0,remaining:0,reset_at:null,is_blocked:false})),
submit:vi.fn(async()=>({response:{} as never,match:null})),update:vi.fn(async()=>({success:true})),responses:vi.fn(async()=>({responses:[],totalCount:0})),matches:vi.fn(async()=>({matches:[],totalCount:0})),markSeen:vi.fn(async()=>undefined),archive:vi.fn(async()=>undefined),streak:vi.fn(async()=>null),close:vi.fn(async()=>undefined)};}
submit:vi.fn(async()=>({response:{} as never,match:null})),update:vi.fn(async()=>({success:true})),responses:vi.fn(async()=>({responses:[],totalCount:0})),matches:vi.fn(async()=>({matches:[],totalCount:0,hasMore:false})),markSeen:vi.fn(async()=>undefined),archive:vi.fn(async()=>undefined),streak:vi.fn(async()=>null),close:vi.fn(async()=>undefined)};}
function app(r:AnswersRepository){const app=new Hono<{Variables:{identity:AuthIdentity}}>();app.use('/v1/*',async(c,n)=>{c.set('identity',identity);await n();});registerAnswerRoutes(app,r);return app;}
describe('answer routes',()=>{
@@ -18,6 +18,16 @@ describe('answer routes',()=>{
it('maps confirmation and domain failures',async()=>{const r=repo();vi.mocked(r.update).mockResolvedValueOnce({success:false,requires_confirmation:true,message_count:2});const a=app(r);expect(await (await a.request(`/v1/responses/${questionId}`,{method:'PATCH',headers:{'content-type':'application/json'},body:'{"new_answer":"no"}'})).json()).toMatchObject({requires_confirmation:true,message_count:2});vi.mocked(r.submit).mockRejectedValueOnce(new AnswersError('daily_limit',429,'Daily response limit reached',{daily_limit:10,responses_today:10,remaining:0,reset_at:'2026-08-29T00:00:00.000Z'}));const blocked=await a.request('/v1/responses',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({question_id:questionId,answer:'yes'})});expect(blocked.status).toBe(429);
// The client renders the limit screen straight from this body, so its shape is a contract.
expect(await blocked.json()).toMatchObject({error:{code:'daily_limit'},details:{daily_limit:10,responses_today:10,remaining:0,reset_at:'2026-08-29T00:00:00.000Z'}});});
it('reports pagination from the repository, including a page with no total',async()=>{const r=repo();const a=app(r);
// Later pages skip the count entirely, so `hasMore` — not a client-side
// comparison against totalCount — is what decides whether to keep paging.
vi.mocked(r.matches).mockResolvedValueOnce({matches:[],totalCount:null,hasMore:true});
const later=await a.request('/v1/matches?page=2&limit=20');
expect(later.status).toBe(200);
expect(await later.json()).toMatchObject({totalCount:null,hasMore:true});
expect(r.matches).toHaveBeenCalledWith(identity.id,2,20,false);
await a.request('/v1/matches?page=0&limit=20&archived=true');
expect(r.matches).toHaveBeenLastCalledWith(identity.id,0,20,true);});
it('scopes match mutations to identity',async()=>{const r=repo();const a=app(r);await a.request('/v1/matches/seen',{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify({ids:[questionId]})});expect(r.markSeen).toHaveBeenCalledWith(identity.id,[questionId]);await a.request(`/v1/matches/${questionId}/archive`,{method:'PUT',headers:{'content-type':'application/json'},body:'{"archived":true}'});expect(r.archive).toHaveBeenCalledWith(identity.id,questionId,true);});
});
+25
View File
@@ -75,6 +75,31 @@ describe.skipIf(!databaseUrl || !localDatabase)('API + PostgreSQL integration',
},
});
// Reading the profile again must not rewrite it. Every authenticated request the
// app makes starts here, so an unconditional upsert turned a read into a write
// on the busiest path in the API.
const stamps = async () => (await pool.query<{ auth_synced_at: Date | null; updated_at: Date }>(
'select auth_synced_at, updated_at from profiles where id = $1',
['33333333-3333-4333-8333-333333333333'],
)).rows[0];
const created = await stamps();
expect(created.auth_synced_at).not.toBeNull();
for (let attempt = 0; attempt < 3; attempt += 1) {
expect((await app.request('/v1/me', { headers })).status).toBe(200);
}
const unchanged = await stamps();
expect(unchanged.auth_synced_at?.getTime()).toBe(created.auth_synced_at?.getTime());
expect(unchanged.updated_at.getTime()).toBe(created.updated_at.getTime());
// A genuine identity change still writes through, so first use and a changed
// email are both recorded rather than skipped.
await pool.query('update profiles set email = $2 where id = $1', ['33333333-3333-4333-8333-333333333333', 'stale@sauci.test']);
expect((await app.request('/v1/me', { headers })).status).toBe(200);
const resynced = await stamps();
expect(resynced.auth_synced_at!.getTime()).toBeGreaterThan(created.auth_synced_at!.getTime());
expect(await (await app.request('/v1/me', { headers })).json()).toMatchObject({ profile: { email: 'integration@sauci.test' } });
expect((await app.request('/v1/me/feature-interests/better-chat', { method: 'PUT', headers })).status).toBe(200);
expect(await (await app.request('/v1/me/feature-interests/better-chat', { headers })).json())
.toEqual({ feature: 'better-chat', interested: true });
@@ -30,6 +30,99 @@ describe.skipIf(!databaseUrl || !local)('app-data repository + PostgreSQL', () =
await pool.query("insert into messages(id,match_id,user_id,media_path,media_type) values($1,$2,$3,'x.jpg','image')",[message,match,alice]);
});
afterAll(async()=>{await repo.close();await pool.end();await admin.query(`drop schema "${schema}" cascade`);await admin.end();});
it('summarises change markers per couple without leaking another couple state',async()=>{
// This one small read replaced a five-second refresh of profile, couple, matches,
// pending questions, packs and unread counts, so its markers have to move for
// exactly the changes those refreshes existed to notice.
const before=await repo.syncSummary(alice);
expect(before).toMatchObject({couple_id:couple,partner_id:bob,match_count:1,new_match_count:1,unread_total:0});
expect(before.profile_updated_at).not.toBeNull();
expect(Date.parse(before.server_time)).not.toBeNaN();
// The partner's own message is unread to them and not to us.
expect((await repo.syncSummary(bob)).unread_total).toBe(1);
// A partner profile edit moves the partner marker, which is what tells the
// client to re-read the couple rather than everything.
await pool.query("update profiles set name='Bobby', updated_at=now() where id=$1",[bob]);
const afterPartner=await repo.syncSummary(alice);
expect(Date.parse(afterPartner.partner_updated_at!)).toBeGreaterThan(Date.parse(before.partner_updated_at!));
expect(afterPartner.profile_updated_at).toBe(before.profile_updated_at);
// couple_packs carries no timestamp, so equal-and-opposite toggles have to be
// caught by a digest rather than a max(updated_at).
const emptyDigest=before.enabled_packs_fingerprint;
expect(emptyDigest).toEqual(expect.any(String));
await pool.query('insert into couple_packs(couple_id,pack_id,enabled) values($1,$2,true)',[couple,pack]);
const enabled=await repo.syncSummary(alice);
expect(enabled.enabled_packs_fingerprint).not.toBe(emptyDigest);
await pool.query('update couple_packs set enabled=false where couple_id=$1 and pack_id=$2',[couple,pack]);
expect((await repo.syncSummary(alice)).enabled_packs_fingerprint).toBe(emptyDigest);
// Toggling back must land on the same digest, so the client stops refetching.
await pool.query('update couple_packs set enabled=true where couple_id=$1 and pack_id=$2',[couple,pack]);
expect((await repo.syncSummary(alice)).enabled_packs_fingerprint).toBe(enabled.enabled_packs_fingerprint);
// An outsider's summary is about their own couple and never ours.
const other=await repo.syncSummary(outsider);
expect(other).toMatchObject({couple_id:otherCouple,partner_id:null,match_count:0,unread_total:0});
});
it('moves the match-state fingerprint on an in-place edit that the count and latest-at markers miss',async()=>{
// Editing an existing match's type/response summary (response editing) creates
// no new match and touches no created_at, so match_count/new_match_count/
// latest_match_at all hold steady; only the state fingerprint must move.
const before=await repo.syncSummary(alice);
await pool.query("update matches set match_type='yes_maybe', response_summary=$2 where id=$1",[match,JSON.stringify({edited:true})]);
const after=await repo.syncSummary(alice);
expect(after.match_count).toBe(before.match_count);
expect(after.new_match_count).toBe(before.new_match_count);
expect(after.latest_match_at).toBe(before.latest_match_at);
expect(after.match_state_fingerprint).not.toBe(before.match_state_fingerprint);
// Reverting lands back on the same digest, so the client stops refetching.
await pool.query("update matches set match_type='yes_yes', response_summary=null where id=$1",[match]);
expect((await repo.syncSummary(alice)).match_state_fingerprint).toBe(before.match_state_fingerprint);
});
it('moves the match-unread fingerprint when unread redistributes without changing the total',async()=>{
const secondMatch='cccccccc-cccc-4ccc-8ccc-cccccccccccc';
const secondQuestion='dddddddd-dddd-4ddd-8ddd-dddddddddddd';
const originalUnread='eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee';
const secondUnread='11111111-2222-4333-8444-555555555555';
await pool.query("insert into questions(id,pack_id,text) values($1,$2,'Question 2')",[secondQuestion,pack]);
await pool.query("insert into matches(id,couple_id,question_id,match_type) values($1,$2,$3,'yes_yes')",[secondMatch,couple,secondQuestion]);
// Alice's own message ('message', from beforeAll) is never unread to her, so
// give the original match a message from her partner to actually redistribute.
await pool.query("insert into messages(id,match_id,user_id,media_path,media_type) values($1,$2,$3,'y.jpg','image')",[originalUnread,match,bob]);
await pool.query("insert into messages(id,match_id,user_id,media_path,media_type) values($1,$2,$3,'y2.jpg','image')",[secondUnread,secondMatch,bob]);
const before=await repo.syncSummary(alice);
expect(before.unread_total).toBe(2);
// Read the original match's unread message while a new one lands in the
// second match: unread_total holds steady at 2, but which match owns it moves.
await pool.query('update messages set read_at=now() where id=$1',[originalUnread]);
const thirdMessage='ffffffff-ffff-4fff-8fff-ffffffffffff';
await pool.query("insert into messages(id,match_id,user_id,media_path,media_type) values($1,$2,$3,'z.jpg','image')",[thirdMessage,secondMatch,bob]);
const after=await repo.syncSummary(alice);
expect(after.unread_total).toBe(before.unread_total);
expect(after.match_unread_fingerprint).not.toBe(before.match_unread_fingerprint);
// Clean up so later assertions in this suite see the original single-match state.
await pool.query('delete from messages where id=any($1::uuid[])',[[originalUnread,secondUnread,thirdMessage]]);
await pool.query('delete from matches where id=$1',[secondMatch]);
await pool.query('delete from questions where id=$1',[secondQuestion]);
});
it('moves the streak marker from couple_streaks.updated_at',async()=>{
await pool.query('insert into couple_streaks(couple_id) values($1) on conflict do nothing',[couple]);
const before=await repo.syncSummary(alice);
expect(before.streak_updated_at).toEqual(expect.any(String));
await pool.query('update couple_streaks set current_streak=current_streak+1, updated_at=now() where couple_id=$1',[couple]);
const after=await repo.syncSummary(alice);
expect(Date.parse(after.streak_updated_at!)).toBeGreaterThan(Date.parse(before.streak_updated_at!));
});
it('returns only public catalog data and couple-owned match context',async()=>{
expect((await repo.packQuestions(alice,pack))[0]?.text).toBe('Question');
expect((await repo.matchContext(bob,match)).responses).toHaveLength(2);
+6
View File
@@ -16,6 +16,12 @@ function repository(): AppDataRepository {
markMediaViewed: vi.fn(async () => ({ media_viewed_at: 'now', media_expires_at: null })),
nudgeStatus: vi.fn(async () => ({ last_nudge_sent_at: null })),
sendNudge: vi.fn(async () => ({ success: true as const, notification_sent: false, reason: 'no_push_token', next_nudge_available_at: 'later' })),
syncSummary: vi.fn(async () => ({
server_time: 'now', couple_id: null, profile_updated_at: null, partner_id: null, partner_updated_at: null,
match_count: 0, new_match_count: 0, latest_match_at: null, pending_yours: 0, pending_theirs: 0,
unread_total: 0, enabled_packs_fingerprint: '',
match_state_fingerprint: '', match_unread_fingerprint: '', streak_updated_at: null,
})),
getLiveDraw: vi.fn(async () => ({ strokes: [], revision: 0, updated_at: null, updated_by: null })),
putLiveDraw: vi.fn(async () => ({ strokes: [], revision: 1, updated_at: 'now', updated_by: identity.id })),
close: vi.fn(async () => undefined),
+99 -3
View File
@@ -48,7 +48,7 @@ describe.skipIf(!databaseUrl || !isLocal)('chat repository + PostgreSQL', () =>
it('sends, lists, counts and reads text messages for couple members only', async () => {
const sent = await repository.sendText(alice, match, 'Hello');
expect((await repository.listMessages(bob, match))[0]?.content).toBe('Hello');
expect((await repository.listMessages(bob, match)).messages[0]?.content).toBe('Hello');
expect(await repository.unreadCounts(bob)).toEqual({ total: 1, by_match: { [match]: 1 } });
expect((await repository.markDelivered(bob, sent.id)).delivered_at).not.toBeNull();
expect((await repository.markMatchRead(bob, match)).updated).toBe(1);
@@ -58,14 +58,110 @@ describe.skipIf(!databaseUrl || !isLocal)('chat repository + PostgreSQL', () =>
it('enforces per-user and author-only deletions plus reports', async () => {
const sent = await repository.sendText(alice, match, 'Moderate me');
await repository.deleteForSelf(bob, sent.id);
expect((await repository.listMessages(bob, match)).some(item => item.id === sent.id)).toBe(false);
expect((await repository.listMessages(alice, match)).some(item => item.id === sent.id)).toBe(true);
expect((await repository.listMessages(bob, match)).messages.some(item => item.id === sent.id)).toBe(false);
expect((await repository.listMessages(alice, match)).messages.some(item => item.id === sent.id)).toBe(true);
await expect(repository.deleteForEveryone(bob, sent.id)).rejects.toMatchObject({ code: 'not_message_author' });
expect((await repository.deleteForEveryone(alice, sent.id)).deleted_at).not.toBeNull();
await repository.report(bob, sent.id, 'harassment');
await expect(repository.report(bob, sent.id, 'spam')).rejects.toBeInstanceOf(ChatError);
});
it('answers a delta with only what changed, and the same rows a full read would give', async () => {
const before = await repository.listMessages(bob, match);
const cursor = before.server_time;
expect(before.complete).toBe(true);
// Nothing has happened since the cursor, so the delta carries no row the client
// has not already seen. The cursor is deliberately held a couple of seconds
// behind wall clock so a write racing the read is repeated rather than lost;
// that repetition is absorbed by merging on id, never by blanking the thread.
const known = new Set(before.messages.map(item => item.id));
const quiet = await repository.listMessages(bob, match, { since: cursor });
expect(quiet.complete).toBe(false);
expect(quiet.messages.every(item => known.has(item.id))).toBe(true);
expect(quiet.removed_ids?.every(id => !known.has(id))).toBe(true);
const fresh = await repository.sendText(alice, match, 'Delta arrival');
const delta = await repository.listMessages(bob, match, { since: cursor });
expect(delta.messages.map(item => item.id)).toContain(fresh.id);
// Media and moderation columns must survive the narrower query.
expect(delta.messages.find(item => item.id === fresh.id)).toMatchObject({
content: 'Delta arrival', media_path: null, media_type: null, media_expired: false, read_at: null,
});
// A status change with no new row still reaches the sender through the delta.
const afterSend = await repository.listMessages(alice, match);
await repository.markMatchRead(bob, match);
const readDelta = await repository.listMessages(alice, match, { since: afterSend.server_time });
expect(readDelta.messages.find(item => item.id === fresh.id)?.read_at).not.toBeNull();
// So does a deletion, which arrives as a removal rather than a silent gap.
const doomed = await repository.sendText(alice, match, 'Delete me');
const beforeDelete = await repository.listMessages(bob, match);
await repository.deleteForSelf(bob, doomed.id);
const removal = await repository.listMessages(bob, match, { since: beforeDelete.server_time });
expect(removal.removed_ids).toContain(doomed.id);
// Ordering is the contract the screen renders against: newest first, both ways.
const full = await repository.listMessages(bob, match);
const timestamps = full.messages.map(item => Date.parse(item.created_at));
expect([...timestamps].sort((a, b) => b - a)).toEqual(timestamps);
// Access control is unchanged on the delta path.
await expect(repository.listMessages(outsider, match, { since: cursor })).rejects.toMatchObject({ code: 'match_not_found' });
});
it('surfaces an expiring video to a chat that is already open, and only in the window it expires', async () => {
// Expiry is the one visible change no write announces: the deadline simply
// passes. Backdated so neither `created_at` nor `media_viewed_at` can be what
// puts the row in a delta — only the deadline crossing may.
const video = await repository.sendText(alice, match, 'Sent a video');
await pool.query(
`update messages
set media_path = 'media:expiring', media_type = 'video',
moderation_status = 'safe', encrypted_content = 'cipher', encryption_iv = 'iv',
created_at = now() - interval '1 hour',
media_viewed_at = now() - interval '1 hour',
media_expires_at = now() + interval '1 hour'
where id = $1`,
[video.id],
);
const before = await repository.listMessages(bob, match);
expect(before.messages.find(item => item.id === video.id)).toMatchObject({ media_expired: false });
// A deadline still in the future is not a change, so the row is not re-sent on
// every poll until it arrives.
const quiet = await repository.listMessages(bob, match, { since: before.server_time });
expect(quiet.messages.some(item => item.id === video.id)).toBe(false);
// The deadline now falls inside this poll's window.
await pool.query(`update messages set media_expires_at = now() - interval '1 second' where id = $1`, [video.id]);
const delta = await repository.listMessages(bob, match, { since: before.server_time });
const expired = delta.messages.find(item => item.id === video.id);
// Media, encryption and moderation columns still arrive whole on the delta path.
expect(expired).toMatchObject({
media_expired: true, media_type: 'video', media_path: 'media:expiring',
moderation_status: 'safe', encrypted_content: 'cipher', encryption_iv: 'iv',
});
expect(expired?.media_expires_at).not.toBeNull();
expect(expired?.media_viewed_at).not.toBeNull();
// A full read agrees, so leaving and re-entering the chat shows the same thing.
expect((await repository.listMessages(bob, match)).messages.find(item => item.id === video.id))
.toMatchObject({ media_expired: true });
});
it('folds the partner typing check into the message read when asked', async () => {
await repository.setTyping(alice, match, 4_000);
const withTyping = await repository.listMessages(bob, match, { includeTyping: true });
expect(withTyping.typing?.typing).toBe(true);
// Own typing is never reflected back, so the sender does not see themselves.
expect((await repository.listMessages(alice, match, { includeTyping: true })).typing?.typing).toBe(false);
// And it stays absent unless requested, so the default read costs no extra work.
expect((await repository.listMessages(bob, match)).typing).toBeUndefined();
});
it('stores typing state with a short expiry and exposes only the partner state', async () => {
await repository.setTyping(alice, match, 4_000);
expect((await repository.getPartnerTyping(bob, match)).typing).toBe(true);
+24 -1
View File
@@ -11,7 +11,8 @@ const message = { id: messageId, match_id: matchId, user_id: identity.id, conten
function setup() {
const repository: ChatRepository = {
listMessages: vi.fn(async () => [message]), sendText: vi.fn(async () => message),
listMessages: vi.fn(async () => ({ messages: [message], removed_ids: [], server_time: new Date().toISOString(), complete: true })),
sendText: vi.fn(async () => message),
unreadCounts: vi.fn(async () => ({ total: 1, by_match: { [matchId]: 1 } })),
markMatchRead: vi.fn(async () => ({ updated: 1, read_at: new Date().toISOString() })),
markDelivered: vi.fn(async () => message), deleteForSelf: vi.fn(async () => undefined),
@@ -43,6 +44,28 @@ describe('chat routes', () => {
expect(repository.sendText).not.toHaveBeenCalled();
});
it('passes a delta cursor and folded typing check through to the repository', async () => {
const { app, repository } = setup();
const since = '2026-08-28T10:00:00.000Z';
const delta = await app.request(`/v1/matches/${matchId}/messages?since=${encodeURIComponent(since)}&typing=true`);
expect(delta.status).toBe(200);
expect(repository.listMessages).toHaveBeenCalledWith(identity.id, matchId, { since, includeTyping: true });
// Omitting both keeps the full-snapshot contract the client had before.
const snapshot = await app.request(`/v1/matches/${matchId}/messages`);
expect(snapshot.status).toBe(200);
expect(await snapshot.json()).toMatchObject({ messages: [{ id: messageId }] });
expect(repository.listMessages).toHaveBeenLastCalledWith(identity.id, matchId, { includeTyping: false });
});
it('refuses a malformed cursor rather than silently reading everything', async () => {
const { app, repository } = setup();
const response = await app.request(`/v1/matches/${matchId}/messages?since=yesterday`);
expect(response.status).toBe(400);
expect(await response.json()).toMatchObject({ error: { code: 'invalid_since' } });
expect(repository.listMessages).not.toHaveBeenCalled();
});
it('supports unread, typing, deletion and reporting actions', async () => {
const { app, repository } = setup();
expect((await app.request('/v1/chat/unread')).status).toBe(200);
+49
View File
@@ -0,0 +1,49 @@
import { Pool } from 'pg';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
DEFAULT_POOL_MAX,
closeResolvedPool,
createDatabasePool,
resolvePool,
} from '../src/db/pool.js';
const url = 'postgres://user:pass@127.0.0.1:5432/sauci_test';
afterEach(() => vi.restoreAllMocks());
describe('database pool', () => {
it('bounds the shared pool and only forwards timeouts that were configured', () => {
// A process-wide pool is the whole point of the change: an unbounded default
// multiplied by one pool per repository is what the self-hosted database had
// to absorb before.
const bounded = createDatabasePool(url, { max: 4, idleTimeoutMillis: 1_000, connectionTimeoutMillis: 2_000 });
expect(bounded.options.max).toBe(4);
expect(bounded.options.idleTimeoutMillis).toBe(1_000);
expect(bounded.options.connectionTimeoutMillis).toBe(2_000);
const defaults = createDatabasePool(url);
expect(defaults.options.max).toBe(DEFAULT_POOL_MAX);
return Promise.all([bounded.end(), defaults.end()]);
});
it('owns a pool it built from a URL and never ends one it was handed', async () => {
// Repositories in the API process share one pool, so closing any single
// repository must not take the connections out from under the others. Tests
// still construct from a URL and must be able to close what they created.
const owned = resolvePool(url);
expect(owned.owned).toBe(true);
const ownedEnd = vi.spyOn(owned.pool, 'end');
await closeResolvedPool(owned.pool, owned.owned);
expect(ownedEnd).toHaveBeenCalledTimes(1);
const shared = new Pool({ connectionString: url });
const borrowed = resolvePool(shared);
expect(borrowed.owned).toBe(false);
expect(borrowed.pool).toBe(shared);
const sharedEnd = vi.spyOn(shared, 'end');
await closeResolvedPool(borrowed.pool, borrowed.owned);
expect(sharedEnd).not.toHaveBeenCalled();
await shared.end();
});
});
+481 -495
View File
@@ -1,509 +1,495 @@
import { Tabs, useRouter, useSegments } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { View, ActivityIndicator, StyleSheet, Platform, Text, Pressable, Animated, AppState } from "react-native";
import { BlurView } from "expo-blur";
import { useEffect, useState, useCallback, useRef } from "react";
import { useAuthStore, useMatchStore, useMessageStore, useSubscriptionStore, usePacksStore, useStreakStore } from "../../src/store";
import { colors, radius, spacing, typography, shadows } from "../../src/theme";
import { Tabs, useRouter, useSegments } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { View, ActivityIndicator, StyleSheet, Platform, Text, Pressable, Animated, AppState } from "react-native";
import { BlurView } from "expo-blur";
import { useEffect, useState, useCallback, useRef } from "react";
import { useAuthStore, useMatchStore, useMessageStore, useSubscriptionStore, useStreakStore } from "../../src/store";
import { colors, radius, spacing, typography, shadows } from "../../src/theme";
import { authClient } from "../../src/lib/authClient";
import { isBiometricEnabled } from "../../src/lib/biometricAuth";
import { checkAndRegisterPushToken } from "../../src/lib/notifications";
import { syncBadgeCount } from "../../src/lib/badge";
import { BiometricLockScreen } from "../../src/components/BiometricLockScreen";
import { Events } from "../../src/lib/analytics";
import { needsOnboarding } from "../../src/constants/onboarding";
import type { Database } from "../../src/types/supabase";
type Message = Database["public"]["Tables"]["messages"]["Row"];
function MessageToastPreview({
message,
}: {
message: Message;
}) {
if (message.media_path) {
const fallback = message.media_type === 'video' ? 'Sent a video' : 'Sent an image';
return (
<Text style={styles.messageToastBody} numberOfLines={1}>
{fallback}
</Text>
);
}
return (
<Text style={styles.messageToastBody} numberOfLines={1}>
{message.content ?? 'New message'}
</Text>
);
}
function TabBarBackground() {
// Only use BlurView on iOS - Android experimental blur causes white overlay artifacts
// The solid rgba background in tabBarStyle provides the visual effect on Android
if (Platform.OS === 'ios') {
return (
<>
<BlurView
intensity={80}
tint="dark"
style={StyleSheet.absoluteFill}
/>
<View style={[StyleSheet.absoluteFill, { backgroundColor: 'rgba(14, 14, 17, 0.85)' }]} />
</>
);
}
return null;
}
export default function AppLayout() {
const router = useRouter();
const segments = useSegments();
const { isAuthenticated, isLoading, user, signOut, updateLastActive } = useAuthStore();
const { newMatchesCount, pendingQuestions, fetchPendingQuestions, fetchMatches } = useMatchStore();
const { unreadCount, lastMessage, fetchUnreadCount, clearLastMessage } = useMessageStore();
const { fetchEnabledPacks } = usePacksStore();
import { isBiometricEnabled } from "../../src/lib/biometricAuth";
import { checkAndRegisterPushToken } from "../../src/lib/notifications";
import { useCoupleSync } from "../../src/hooks/useCoupleSync";
import { syncBadgeCount } from "../../src/lib/badge";
import { BiometricLockScreen } from "../../src/components/BiometricLockScreen";
import { Events } from "../../src/lib/analytics";
import { needsOnboarding } from "../../src/constants/onboarding";
import type { Database } from "../../src/types/supabase";
type Message = Database["public"]["Tables"]["messages"]["Row"];
function MessageToastPreview({
message,
}: {
message: Message;
}) {
if (message.media_path) {
const fallback = message.media_type === 'video' ? 'Sent a video' : 'Sent an image';
return (
<Text style={styles.messageToastBody} numberOfLines={1}>
{fallback}
</Text>
);
}
return (
<Text style={styles.messageToastBody} numberOfLines={1}>
{message.content ?? 'New message'}
</Text>
);
}
function TabBarBackground() {
// Only use BlurView on iOS - Android experimental blur causes white overlay artifacts
// The solid rgba background in tabBarStyle provides the visual effect on Android
if (Platform.OS === 'ios') {
return (
<>
<BlurView
intensity={80}
tint="dark"
style={StyleSheet.absoluteFill}
/>
<View style={[StyleSheet.absoluteFill, { backgroundColor: 'rgba(14, 14, 17, 0.85)' }]} />
</>
);
}
return null;
}
export default function AppLayout() {
const router = useRouter();
const segments = useSegments();
const { isAuthenticated, isLoading, user, signOut, updateLastActive } = useAuthStore();
const { newMatchesCount, pendingQuestions, matches, fetchPendingQuestions, fetchMatches } = useMatchStore();
const { unreadCount, lastMessage, clearLastMessage } = useMessageStore();
const { initializeRevenueCat, refreshSubscriptionStatus } = useSubscriptionStore();
const { fetchStreak } = useStreakStore();
const messageToastAnim = useRef(new Animated.Value(-100)).current;
const { fetchStreak } = useStreakStore();
const messageToastAnim = useRef(new Animated.Value(-100)).current;
const messageTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [isLocked, setIsLocked] = useState(false);
const hasCheckedInitialBiometric = useRef(false);
const wentToBackgroundAt = useRef<number | null>(null);
const [isLocked, setIsLocked] = useState(false);
const hasCheckedInitialBiometric = useRef(false);
const wentToBackgroundAt = useRef<number | null>(null);
const knownMatchIdsRef = useRef<Set<string>>(new Set());
const matchPollInitializedRef = useRef(false);
// Check if we're on screens that should hide the tab bar
const segmentStrings = segments as string[];
const isOnOnboarding = segmentStrings.includes("onboarding");
const isOnPairing = segmentStrings.includes("pairing");
const isOnChat = segmentStrings.includes("chat");
const isOnLiveDraw = segmentStrings.includes("live-draw");
const isOnSettingsSubscreen = segmentStrings.includes("settings") && segmentStrings.length > 2;
const shouldHideTabBar = isOnOnboarding || isOnPairing || isOnChat || isOnLiveDraw || isOnSettingsSubscreen;
// Redirect to login when not authenticated, or to onboarding if not completed/outdated
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.replace("/(auth)/login");
} else if (!isLoading && isAuthenticated && user && needsOnboarding(user.onboarding_completed, user.onboarding_version) && !isOnOnboarding) {
router.replace("/(app)/onboarding");
}
}, [isLoading, isAuthenticated, user, router, isOnOnboarding]);
// Check session validity on mount and when app comes to foreground
useEffect(() => {
const checkSession = async () => {
// Check if we're on screens that should hide the tab bar
const segmentStrings = segments as string[];
const isOnOnboarding = segmentStrings.includes("onboarding");
const isOnPairing = segmentStrings.includes("pairing");
const isOnChat = segmentStrings.includes("chat");
const isOnLiveDraw = segmentStrings.includes("live-draw");
const isOnSettingsSubscreen = segmentStrings.includes("settings") && segmentStrings.length > 2;
const shouldHideTabBar = isOnOnboarding || isOnPairing || isOnChat || isOnLiveDraw || isOnSettingsSubscreen;
// Redirect to login when not authenticated, or to onboarding if not completed/outdated
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.replace("/(auth)/login");
} else if (!isLoading && isAuthenticated && user && needsOnboarding(user.onboarding_completed, user.onboarding_version) && !isOnOnboarding) {
router.replace("/(app)/onboarding");
}
}, [isLoading, isAuthenticated, user, router, isOnOnboarding]);
// Check session validity on mount and when app comes to foreground
useEffect(() => {
const checkSession = async () => {
const { data: { session } } = await authClient.auth.getSession();
if (!session) {
// No valid session - sign out to clear local state
signOut();
}
};
// Check immediately
checkSession();
// Update last active timestamp on mount
updateLastActive();
// Check when app comes to foreground
const subscription = AppState.addEventListener('change', (nextAppState) => {
if (nextAppState === 'active') {
checkSession();
// Update last active timestamp when app comes to foreground
updateLastActive();
if (!session) {
// No valid session - sign out to clear local state
signOut();
}
};
// Check immediately
checkSession();
// Update last active timestamp on mount
updateLastActive();
// Check when app comes to foreground
const subscription = AppState.addEventListener('change', (nextAppState) => {
if (nextAppState === 'active') {
checkSession();
// Update last active timestamp when app comes to foreground
updateLastActive();
if (user?.id && Platform.OS !== "web") {
void refreshSubscriptionStatus();
}
}
});
return () => {
subscription.remove();
};
}
});
return () => {
subscription.remove();
};
}, [signOut, updateLastActive, user?.id, refreshSubscriptionStatus]);
// Handle biometric lock when app goes to background/foreground
useEffect(() => {
// Check if biometric should be shown on initial mount
const checkInitialBiometric = async () => {
if (hasCheckedInitialBiometric.current) return;
hasCheckedInitialBiometric.current = true;
const enabled = await isBiometricEnabled();
if (enabled && isAuthenticated) {
setIsLocked(true);
}
};
if (isAuthenticated && !isLoading) {
checkInitialBiometric();
}
const subscription = AppState.addEventListener('change', async (nextAppState) => {
// Track when app goes to background (not just inactive)
if (nextAppState === 'background') {
wentToBackgroundAt.current = Date.now();
}
// When app becomes active, check if we should lock
if (nextAppState === 'active' && isAuthenticated && !isLocked) {
// Only lock if we actually went to background (not just inactive from modal/control center)
// and we were in background for at least 1 second
if (wentToBackgroundAt.current) {
const timeInBackground = Date.now() - wentToBackgroundAt.current;
wentToBackgroundAt.current = null;
if (timeInBackground > 1000) {
const enabled = await isBiometricEnabled();
if (enabled) {
setIsLocked(true);
}
}
}
}
});
return () => {
subscription.remove();
};
}, [isAuthenticated, isLoading, isLocked]);
const handleBiometricUnlock = useCallback(() => {
setIsLocked(false);
}, []);
// Fetch unread message count on mount
useEffect(() => {
if (user?.id) {
fetchUnreadCount();
}
}, [user?.id, fetchUnreadCount]);
// Fetch streak data when user is in a couple
useEffect(() => {
if (user?.couple_id) {
fetchStreak();
}
}, [user?.couple_id, fetchStreak]);
// Fetch pending questions (Your Turn) when user is in a couple
useEffect(() => {
if (user?.couple_id) {
fetchPendingQuestions();
}
}, [user?.couple_id, fetchPendingQuestions]);
// Sync app icon badge count whenever matches, messages, or pending questions change
useEffect(() => {
syncBadgeCount(newMatchesCount + pendingQuestions.length, unreadCount);
}, [newMatchesCount, unreadCount, pendingQuestions.length]);
// Initialize RevenueCat for subscription management (iOS and Android only)
useEffect(() => {
if (user?.id && Platform.OS !== "web") {
initializeRevenueCat(user.id);
}
}, [user?.id, initializeRevenueCat]);
// Check and register push token for this device
// This handles the case where user onboarded on a different device
useEffect(() => {
if (user?.id && user?.onboarding_completed) {
checkAndRegisterPushToken(user.id);
}
}, [user?.id, user?.onboarding_completed]);
// Poll the self-hosted API for profile, match, pending-question and pack changes.
// This replaces the former Supabase Postgres change subscriptions.
useEffect(() => {
if (!user?.id) return;
let cancelled = false;
// Handle biometric lock when app goes to background/foreground
useEffect(() => {
// Check if biometric should be shown on initial mount
const checkInitialBiometric = async () => {
if (hasCheckedInitialBiometric.current) return;
hasCheckedInitialBiometric.current = true;
const enabled = await isBiometricEnabled();
if (enabled && isAuthenticated) {
setIsLocked(true);
}
};
if (isAuthenticated && !isLoading) {
checkInitialBiometric();
}
const subscription = AppState.addEventListener('change', async (nextAppState) => {
// Track when app goes to background (not just inactive)
if (nextAppState === 'background') {
wentToBackgroundAt.current = Date.now();
}
// When app becomes active, check if we should lock
if (nextAppState === 'active' && isAuthenticated && !isLocked) {
// Only lock if we actually went to background (not just inactive from modal/control center)
// and we were in background for at least 1 second
if (wentToBackgroundAt.current) {
const timeInBackground = Date.now() - wentToBackgroundAt.current;
wentToBackgroundAt.current = null;
if (timeInBackground > 1000) {
const enabled = await isBiometricEnabled();
if (enabled) {
setIsLocked(true);
}
}
}
}
});
return () => {
subscription.remove();
};
}, [isAuthenticated, isLoading, isLocked]);
const handleBiometricUnlock = useCallback(() => {
setIsLocked(false);
}, []);
// The unread badge arrives with the first sync summary, so it needs no request
// of its own here.
// Fetch streak data when user is in a couple
useEffect(() => {
if (user?.couple_id) {
fetchStreak();
}
}, [user?.couple_id, fetchStreak]);
// Fetch pending questions (Your Turn) and matches when user is in a couple.
// Both feed tab badges, so they load once here and are kept current by the
// sync summary rather than by re-fetching on a timer.
useEffect(() => {
if (user?.couple_id) {
void fetchPendingQuestions();
void fetchMatches(true);
}
}, [user?.couple_id, fetchPendingQuestions, fetchMatches]);
// Sync app icon badge count whenever matches, messages, or pending questions change
useEffect(() => {
syncBadgeCount(newMatchesCount + pendingQuestions.length, unreadCount);
}, [newMatchesCount, unreadCount, pendingQuestions.length]);
// Initialize RevenueCat for subscription management (iOS and Android only)
useEffect(() => {
if (user?.id && Platform.OS !== "web") {
initializeRevenueCat(user.id);
}
}, [user?.id, initializeRevenueCat]);
// Check and register push token for this device
// This handles the case where user onboarded on a different device
useEffect(() => {
if (user?.id && user?.onboarding_completed) {
checkAndRegisterPushToken(user.id);
}
}, [user?.id, user?.onboarding_completed]);
// One small change summary drives every domain refresh. It replaces a
// five-second refetch of profile, matches, pending questions and packs plus a
// three-second unread poll, and only asks a store to reload when the server
// reports that its data actually moved.
useCoupleSync(user?.id, user?.couple_id);
// Match analytics are derived from the store rather than from a poll response,
// so they fire once per genuinely new match however it arrived.
useEffect(() => {
matchPollInitializedRef.current = false;
knownMatchIdsRef.current = new Set();
}, [user?.id, user?.couple_id]);
const poll = async () => {
await Promise.allSettled([
useAuthStore.getState().fetchUser(),
...(user.couple_id ? [fetchMatches(true), fetchPendingQuestions(), fetchEnabledPacks()] : []),
]);
if (cancelled || !user.couple_id) return;
const refreshed = useMatchStore.getState().matches;
if (matchPollInitializedRef.current) {
const added = refreshed.filter(item => !knownMatchIdsRef.current.has(item.id));
for (const item of added) Events.matchCreated(item.match_type || "unknown");
const previousCount = knownMatchIdsRef.current.size;
if (previousCount === 0 && added.length > 0) Events.firstMatch();
else if (added.length > 0 && refreshed.length % 10 === 0) Events.milestoneMatch(refreshed.length);
useEffect(() => {
if (!user?.couple_id) return;
const known = knownMatchIdsRef.current;
const added = matches.filter(item => !known.has(item.id));
if (matchPollInitializedRef.current && added.length > 0) {
for (const item of added) Events.matchCreated(item.match_type || "unknown");
if (known.size === 0) Events.firstMatch();
else if (matches.length % 10 === 0) Events.milestoneMatch(matches.length);
}
knownMatchIdsRef.current = new Set(matches.map(item => item.id));
matchPollInitializedRef.current = true;
}, [matches, user?.couple_id]);
// Animate message toast when lastMessage changes
useEffect(() => {
if (lastMessage) {
// Clear any existing timeout
if (messageTimeoutRef.current) {
clearTimeout(messageTimeoutRef.current);
}
knownMatchIdsRef.current = new Set(refreshed.map(item => item.id));
matchPollInitializedRef.current = true;
};
void poll();
const timer = setInterval(() => void poll(), 5_000);
return () => { cancelled = true; clearInterval(timer); };
}, [user?.id, user?.couple_id, fetchMatches, fetchPendingQuestions, fetchEnabledPacks]);
// Poll the self-hosted API for unread chat state. Chat screens perform their
// own message polling; this keeps the global badge current without Supabase Realtime.
useEffect(() => {
if (!user?.couple_id) return;
const timer = setInterval(() => void fetchUnreadCount(), 3_000);
return () => clearInterval(timer);
}, [user?.couple_id, fetchUnreadCount]);
// Animate message toast when lastMessage changes
useEffect(() => {
if (lastMessage) {
// Clear any existing timeout
if (messageTimeoutRef.current) {
clearTimeout(messageTimeoutRef.current);
}
// Slide in
Animated.spring(messageToastAnim, {
toValue: 0,
useNativeDriver: true,
tension: 80,
friction: 10,
}).start();
// Auto-dismiss after 4 seconds
messageTimeoutRef.current = setTimeout(() => {
dismissMessageToast();
}, 4000);
}
return () => {
if (messageTimeoutRef.current) {
clearTimeout(messageTimeoutRef.current);
}
};
}, [lastMessage]);
const dismissMessageToast = useCallback(() => {
Animated.timing(messageToastAnim, {
toValue: -100,
duration: 200,
useNativeDriver: true,
}).start(() => {
clearLastMessage();
});
}, [messageToastAnim, clearLastMessage]);
const handleMessageToastPress = useCallback(() => {
if (lastMessage?.match_id) {
dismissMessageToast();
router.push(`/(app)/chat/${lastMessage.match_id}`);
}
}, [lastMessage, dismissMessageToast, router]);
// Show loading state while checking authentication or redirecting to onboarding
if (isLoading || !isAuthenticated || (user && needsOnboarding(user.onboarding_completed, user.onboarding_version) && !isOnOnboarding)) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
);
}
return (
<>
<Tabs
screenOptions={{
headerShown: false,
tabBarBackground: () => <TabBarBackground />,
tabBarStyle: shouldHideTabBar ? { display: 'none' } : {
position: 'absolute',
backgroundColor: 'rgba(14, 14, 17, 0.85)',
borderTopColor: 'rgba(233, 69, 96, 0.2)', // primary brand color
borderTopWidth: 1,
paddingTop: 8,
paddingBottom: Platform.OS === 'ios' ? 28 : 8,
height: Platform.OS === 'ios' ? 88 : 64,
elevation: 0,
},
tabBarActiveTintColor: colors.primary,
tabBarInactiveTintColor: colors.textSecondary,
tabBarLabelStyle: {
fontSize: 10,
fontWeight: "500",
letterSpacing: 0.5,
},
}}
>
<Tabs.Screen
name="index"
options={{
title: "Home",
tabBarIcon: ({ color, size }) => (
<Ionicons name="home" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="swipe"
options={{
href: null, // Hide from tab bar - accessed from home screen
}}
/>
<Tabs.Screen
name="matches"
options={{
title: "Matches",
tabBarIcon: ({ color, size }) => (
<Ionicons name="heart" size={size} color={color} />
),
tabBarBadge: (newMatchesCount + unreadCount + pendingQuestions.length) > 0 ? (newMatchesCount + unreadCount + pendingQuestions.length) : undefined,
tabBarBadgeStyle: {
backgroundColor: colors.premium.rose,
fontSize: 9,
fontWeight: '600',
minWidth: 16,
height: 16,
borderRadius: 8,
},
}}
/>
<Tabs.Screen
name="profile"
options={{
title: "Settings",
tabBarIcon: ({ color, size }) => (
<Ionicons name="settings" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="pairing"
options={{
href: null, // Hide from tab bar
}}
/>
<Tabs.Screen
name="chat/[id]"
options={{
href: null, // Hide from tab bar
}}
/>
<Tabs.Screen
name="onboarding"
options={{
href: null, // Hide from tab bar
}}
/>
<Tabs.Screen
name="dares"
options={{
href: null, // Hide from tab bar
}}
/>
<Tabs.Screen
name="quiz"
options={{
href: null, // Hide from tab bar
}}
/>
<Tabs.Screen
name="my-answers"
options={{
href: null, // Hide from tab bar
}}
/>
<Tabs.Screen
name="settings"
options={{
href: null, // Hide from tab bar
}}
/>
<Tabs.Screen
name="live-draw"
options={{
href: null, // Hide from tab bar
}}
/>
</Tabs>
{/* Message Toast Notification */}
{lastMessage && (
<Animated.View
style={[
styles.messageToast,
{ transform: [{ translateY: messageToastAnim }] },
]}
>
<Pressable style={styles.messageToastContent} onPress={handleMessageToastPress}>
<View style={styles.messageToastIcon}>
<Ionicons name="chatbubble" size={20} color={colors.primary} />
</View>
<View style={styles.messageToastText}>
<Text style={styles.messageToastTitle} numberOfLines={1}>
New message
</Text>
<MessageToastPreview message={lastMessage} />
</View>
<Pressable onPress={dismissMessageToast} hitSlop={8}>
<Ionicons name="close" size={20} color={colors.textTertiary} />
</Pressable>
</Pressable>
</Animated.View>
)}
{/* Biometric Lock Screen */}
<BiometricLockScreen
visible={isLocked}
onUnlock={handleBiometricUnlock}
/>
</>
);
}
const styles = StyleSheet.create({
loadingContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: colors.background,
},
messageToast: {
position: 'absolute',
top: Platform.OS === 'ios' ? 50 : 20,
left: spacing.md,
right: spacing.md,
zIndex: 1000,
},
messageToastContent: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.backgroundLight,
borderRadius: radius.lg,
padding: spacing.md,
borderWidth: 1,
borderColor: colors.glass.border,
...shadows.lg,
},
messageToastIcon: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: colors.primaryLight,
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.md,
},
messageToastText: {
flex: 1,
marginRight: spacing.sm,
},
messageToastTitle: {
...typography.subhead,
color: colors.text,
fontWeight: '600',
},
messageToastBody: {
...typography.caption1,
color: colors.textSecondary,
marginTop: 2,
},
});
// Slide in
Animated.spring(messageToastAnim, {
toValue: 0,
useNativeDriver: true,
tension: 80,
friction: 10,
}).start();
// Auto-dismiss after 4 seconds
messageTimeoutRef.current = setTimeout(() => {
dismissMessageToast();
}, 4000);
}
return () => {
if (messageTimeoutRef.current) {
clearTimeout(messageTimeoutRef.current);
}
};
}, [lastMessage]);
const dismissMessageToast = useCallback(() => {
Animated.timing(messageToastAnim, {
toValue: -100,
duration: 200,
useNativeDriver: true,
}).start(() => {
clearLastMessage();
});
}, [messageToastAnim, clearLastMessage]);
const handleMessageToastPress = useCallback(() => {
if (lastMessage?.match_id) {
dismissMessageToast();
router.push(`/(app)/chat/${lastMessage.match_id}`);
}
}, [lastMessage, dismissMessageToast, router]);
// Show loading state while checking authentication or redirecting to onboarding
if (isLoading || !isAuthenticated || (user && needsOnboarding(user.onboarding_completed, user.onboarding_version) && !isOnOnboarding)) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
);
}
return (
<>
<Tabs
screenOptions={{
headerShown: false,
tabBarBackground: () => <TabBarBackground />,
tabBarStyle: shouldHideTabBar ? { display: 'none' } : {
position: 'absolute',
backgroundColor: 'rgba(14, 14, 17, 0.85)',
borderTopColor: 'rgba(233, 69, 96, 0.2)', // primary brand color
borderTopWidth: 1,
paddingTop: 8,
paddingBottom: Platform.OS === 'ios' ? 28 : 8,
height: Platform.OS === 'ios' ? 88 : 64,
elevation: 0,
},
tabBarActiveTintColor: colors.primary,
tabBarInactiveTintColor: colors.textSecondary,
tabBarLabelStyle: {
fontSize: 10,
fontWeight: "500",
letterSpacing: 0.5,
},
}}
>
<Tabs.Screen
name="index"
options={{
title: "Home",
tabBarIcon: ({ color, size }) => (
<Ionicons name="home" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="swipe"
options={{
href: null, // Hide from tab bar - accessed from home screen
}}
/>
<Tabs.Screen
name="matches"
options={{
title: "Matches",
tabBarIcon: ({ color, size }) => (
<Ionicons name="heart" size={size} color={color} />
),
tabBarBadge: (newMatchesCount + unreadCount + pendingQuestions.length) > 0 ? (newMatchesCount + unreadCount + pendingQuestions.length) : undefined,
tabBarBadgeStyle: {
backgroundColor: colors.premium.rose,
fontSize: 9,
fontWeight: '600',
minWidth: 16,
height: 16,
borderRadius: 8,
},
}}
/>
<Tabs.Screen
name="profile"
options={{
title: "Settings",
tabBarIcon: ({ color, size }) => (
<Ionicons name="settings" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="pairing"
options={{
href: null, // Hide from tab bar
}}
/>
<Tabs.Screen
name="chat/[id]"
options={{
href: null, // Hide from tab bar
}}
/>
<Tabs.Screen
name="onboarding"
options={{
href: null, // Hide from tab bar
}}
/>
<Tabs.Screen
name="dares"
options={{
href: null, // Hide from tab bar
}}
/>
<Tabs.Screen
name="quiz"
options={{
href: null, // Hide from tab bar
}}
/>
<Tabs.Screen
name="my-answers"
options={{
href: null, // Hide from tab bar
}}
/>
<Tabs.Screen
name="settings"
options={{
href: null, // Hide from tab bar
}}
/>
<Tabs.Screen
name="live-draw"
options={{
href: null, // Hide from tab bar
}}
/>
</Tabs>
{/* Message Toast Notification */}
{lastMessage && (
<Animated.View
style={[
styles.messageToast,
{ transform: [{ translateY: messageToastAnim }] },
]}
>
<Pressable style={styles.messageToastContent} onPress={handleMessageToastPress}>
<View style={styles.messageToastIcon}>
<Ionicons name="chatbubble" size={20} color={colors.primary} />
</View>
<View style={styles.messageToastText}>
<Text style={styles.messageToastTitle} numberOfLines={1}>
New message
</Text>
<MessageToastPreview message={lastMessage} />
</View>
<Pressable onPress={dismissMessageToast} hitSlop={8}>
<Ionicons name="close" size={20} color={colors.textTertiary} />
</Pressable>
</Pressable>
</Animated.View>
)}
{/* Biometric Lock Screen */}
<BiometricLockScreen
visible={isLocked}
onUnlock={handleBiometricUnlock}
/>
</>
);
}
const styles = StyleSheet.create({
loadingContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: colors.background,
},
messageToast: {
position: 'absolute',
top: Platform.OS === 'ios' ? 50 : 20,
left: spacing.md,
right: spacing.md,
zIndex: 1000,
},
messageToastContent: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.backgroundLight,
borderRadius: radius.lg,
padding: spacing.md,
borderWidth: 1,
borderColor: colors.glass.border,
...shadows.lg,
},
messageToastIcon: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: colors.primaryLight,
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.md,
},
messageToastText: {
flex: 1,
marginRight: spacing.sm,
},
messageToastTitle: {
...typography.subhead,
color: colors.text,
fontWeight: '600',
},
messageToastBody: {
...typography.caption1,
color: colors.textSecondary,
marginTop: 2,
},
});
+7 -7
View File
@@ -4,7 +4,7 @@ import { useFocusEffect } from 'expo-router';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { Ionicons } from '@expo/vector-icons';
import { BlurView } from 'expo-blur';
import { useAuthStore, usePacksStore, useStreakStore, useSubscriptionStore } from '../../src/store';
import { useAuthStore, usePacksStore, useSubscriptionStore } from '../../src/store';
import { GradientBackground } from '../../src/components/ui';
import { Paywall } from '../../src/components/paywall';
import { CompactHeader, ContentRow, DaresTile, LiveDrawTile } from '../../src/components/discovery';
@@ -68,8 +68,7 @@ function groupPacksByCategory(
export default function DiscoveryScreen() {
const { user, partner, couple } = useAuthStore();
const { packs, categories, fetchPacks, getPackProgress } = usePacksStore();
const { fetchStreak } = useStreakStore();
const { packs, categories, fetchPacks, ensurePacksLoaded, getPackProgress } = usePacksStore();
const { subscription } = useSubscriptionStore();
const { width } = useWindowDimensions();
const [showPaywall, setShowPaywall] = useState(false);
@@ -79,12 +78,13 @@ export default function DiscoveryScreen() {
const isPremiumUser = user?.is_premium || partner?.is_premium || subscription.isProUser;
const isWideScreen = width > MAX_CONTENT_WIDTH;
// Refresh data when screen gains focus
// Load the catalog once. Returning to this tab shows what is cached; answering a
// question invalidates it, and StreakDisplay owns the streak, so neither needs a
// request here.
useFocusEffect(
useCallback(() => {
fetchPacks();
fetchStreak();
}, [fetchStreak])
void ensurePacksLoaded();
}, [ensurePacksLoaded])
);
// Group packs by category
+18 -8
View File
@@ -39,6 +39,7 @@ export default function MatchesScreen() {
fetchMatches,
markAllAsSeen,
isLoading,
isRefreshing,
hasMore,
isLoadingMore,
totalCount,
@@ -58,7 +59,9 @@ export default function MatchesScreen() {
fetchTheirTurnQuestions,
currentView,
setCurrentView,
ensureViewLoaded,
fetchPendingQuestions,
fetchArchivedMatches,
// Nudge state and methods
nudgeCooldownUntil,
isNudging,
@@ -118,14 +121,12 @@ export default function MatchesScreen() {
setShowTutorial(false);
};
// Load whichever view is showing, once. Returning to this tab reuses what is
// already cached; the sync poll refreshes it when the server reports a change,
// and pull-to-refresh remains the explicit way to force one.
useFocusEffect(
useCallback(() => {
fetchMatches(true);
// Also refresh pending questions when focused
fetchPendingQuestions();
fetchTheirTurnQuestions();
// Check nudge cooldown
checkNudgeCooldown();
ensureViewLoaded();
// Cleanup: mark matches as seen when leaving the screen (if on Complete tab)
return () => {
@@ -134,9 +135,15 @@ export default function MatchesScreen() {
state.markAllAsSeen();
}
};
}, [])
}, [ensureViewLoaded])
);
// The nudge cooldown only moves when this device sends a nudge, and sendNudge
// already records the new cooldown, so one read on mount is enough.
useEffect(() => {
void checkNudgeCooldown();
}, [checkNudgeCooldown]);
// Format cooldown remaining time
const formatCooldownTime = (cooldownUntil: Date): string => {
const now = new Date();
@@ -633,13 +640,16 @@ export default function MatchesScreen() {
refreshing={
currentView === 'pending' ? isLoadingPending :
currentView === 'their_turn' ? isLoadingTheirTurn :
isLoading
currentView === 'archived' ? isLoadingArchived :
isRefreshing
}
onRefresh={() => {
if (currentView === 'pending') {
fetchPendingQuestions();
} else if (currentView === 'their_turn') {
fetchTheirTurnQuestions();
} else if (currentView === 'archived') {
fetchArchivedMatches();
} else {
fetchMatches(true);
}
+6 -5
View File
@@ -47,7 +47,7 @@ const AnimatedSectionList = Animated.createAnimatedComponent(SectionList);
type GroupByOption = "pack" | "date" | "answer";
export default function MyAnswersScreen() {
const { responses, isLoading, isLoadingMore, hasMore, groupBy, dateSortOrder, fetchResponses, setGroupBy, toggleDateSortOrder, totalCount } = useResponsesStore();
const { responses, isLoading, isRefreshing, isLoadingMore, hasMore, groupBy, dateSortOrder, fetchResponses, ensureResponsesLoaded, setGroupBy, toggleDateSortOrder, totalCount } = useResponsesStore();
const { user, partner } = useAuthStore();
const router = useRouter();
const params = useLocalSearchParams();
@@ -110,11 +110,12 @@ export default function MyAnswersScreen() {
// Ambient orb breathing animations
const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation();
// Fetch responses on focus
// Load once, then keep showing what is cached. Answering or editing a response
// invalidates it, and pull-to-refresh is always available.
useFocusEffect(
useCallback(() => {
fetchResponses(true);
}, [])
void ensureResponsesLoaded();
}, [ensureResponsesLoaded])
);
const handleLoadMore = useCallback(() => {
@@ -271,7 +272,7 @@ export default function MyAnswersScreen() {
scrollEventThrottle={16}
refreshControl={
<RefreshControl
refreshing={isLoading}
refreshing={isRefreshing}
onRefresh={() => fetchResponses(true)}
tintColor={colors.primary}
colors={[colors.primary]}
+2 -4
View File
@@ -44,7 +44,7 @@ const AnimatedFlatList = Animated.createAnimatedComponent(FlatList<Question>);
export default function PackDetailsScreen() {
const { id } = useLocalSearchParams();
const router = useRouter();
const { packs, fetchPacks } = usePacksStore();
const { packs, ensurePacksLoaded } = usePacksStore();
const { subscription } = useSubscriptionStore();
const { user, partner } = useAuthStore();
const [questions, setQuestions] = useState<Question[]>([]);
@@ -61,9 +61,7 @@ export default function PackDetailsScreen() {
const category = categories.find(c => c.id === pack?.category_id);
useEffect(() => {
if (packs.length === 0) {
fetchPacks();
}
void ensurePacksLoaded();
fetchPackDetails();
}, [id]);
@@ -17,13 +17,19 @@ jest.mock("expo-router", () => ({
jest.mock("@/store", () => {
const auth = { user: { id: "user-1" }, partner: { id: "user-2" }, couple: { id: "couple-1", couple_id: "couple-1" } };
const packs = { enabledPackIds: ["pack-1"], ensureEnabledPacksLoaded: async () => undefined, packs: [] };
const packState = { ...packs, invalidatePacks: jest.fn() };
const responses = { invalidateResponses: jest.fn() };
const useAuthStore = () => auth;
useAuthStore.getState = () => auth.user;
const usePacksStore = () => packs;
usePacksStore.getState = () => packState;
const useResponsesStore = () => responses;
useResponsesStore.getState = () => responses;
// Read lazily: the factory runs before this module's own consts are initialised.
const streak = { fetchStreak: () => mockFetchStreak() };
const useStreakStore = () => streak;
useStreakStore.getState = () => streak;
return { useAuthStore, usePacksStore: () => packs, useStreakStore };
return { useAuthStore, usePacksStore, useResponsesStore, useStreakStore };
});
jest.mock("@/lib/skippedQuestions", () => ({
@@ -45,17 +51,19 @@ const questions = [
{ id: "q2", pack_id: "pack-1", question_type: "swipe", text: "Two" },
];
const futureResetAt = "2099-08-29T00:00:00.000Z";
const openLimit = {
responses_today: 4,
limit_value: 10,
remaining: 6,
reset_at: "2026-08-29T00:00:00.000Z",
reset_at: futureResetAt,
is_blocked: false,
};
const dailyLimit429 = () => new ApiError("Daily response limit reached", 429, {
error: { code: "daily_limit", message: "Daily response limit reached" },
details: { daily_limit: 10, responses_today: 10, remaining: 0, reset_at: "2026-08-29T00:00:00.000Z" },
details: { daily_limit: 10, responses_today: 10, remaining: 0, reset_at: futureResetAt },
});
// clearMocks wipes implementations between tests, so re-arm the whole surface each time.
@@ -92,7 +100,7 @@ describe("useSwipeScreen daily limit", () => {
responses_today: 10,
limit_value: 10,
remaining: 0,
reset_at: "2026-08-29T00:00:00.000Z",
reset_at: futureResetAt,
is_blocked: true,
});
});
@@ -0,0 +1,313 @@
import { act, renderHook } from '@testing-library/react-native';
import { useCoupleSync, SYNC_INTERVAL_MS } from '@/hooks/useCoupleSync';
import { appDataApi, type SyncSummary } from '@/lib/appDataApi';
import { useAuthStore } from '@/store/authStore';
import { useMatchStore } from '@/store/matchStore';
import { useMessageStore } from '@/store/messageStore';
import { usePacksStore } from '@/store/packsStore';
import { useStreakStore } from '@/store/streakStore';
jest.mock('@/lib/appDataApi', () => ({ appDataApi: { syncSummary: jest.fn() } }));
const mockForeground = { value: true };
const mockListeners = new Set<(foreground: boolean) => void>();
jest.mock('@/lib/appForeground', () => ({
isForeground: () => mockForeground.value,
subscribeToForeground: (listener: (foreground: boolean) => void) => {
mockListeners.add(listener);
return () => { mockListeners.delete(listener); };
},
}));
const baseline: SyncSummary = {
server_time: '2026-08-27T12:00:00.000Z',
couple_id: 'couple',
profile_updated_at: '2026-08-01T00:00:00.000Z',
partner_id: 'partner',
partner_updated_at: '2026-08-01T00:00:00.000Z',
match_count: 3,
new_match_count: 0,
latest_match_at: '2026-08-20T00:00:00.000Z',
pending_yours: 1,
pending_theirs: 2,
unread_total: 0,
enabled_packs_fingerprint: 'digest-a',
match_state_fingerprint: 'state-a',
match_unread_fingerprint: 'unread-a',
streak_updated_at: '2026-08-01T00:00:00.000Z',
};
const flush = async () => { await act(async () => { await Promise.resolve(); await Promise.resolve(); }); };
const tick = async () => {
await flush();
await act(async () => { jest.advanceTimersByTime(SYNC_INTERVAL_MS); });
await flush();
};
/** Replaces every domain refresh the sync can dispatch with a counter. */
function stubRefreshers() {
const fetchUser = jest.fn(async () => undefined);
const fetchCouple = jest.fn(async () => undefined);
const fetchMatches = jest.fn(async () => undefined);
const fetchPendingQuestions = jest.fn(async () => undefined);
const fetchTheirTurnQuestions = jest.fn(async () => undefined);
const fetchEnabledPacks = jest.fn(async () => undefined);
const refreshStreak = jest.fn(async () => undefined);
useAuthStore.setState({ fetchUser, fetchCouple } as never);
useMatchStore.setState({ fetchMatches, fetchPendingQuestions, fetchTheirTurnQuestions } as never);
usePacksStore.setState({ fetchEnabledPacks } as never);
useStreakStore.setState({ refreshStreak } as never);
return { fetchUser, fetchCouple, fetchMatches, fetchPendingQuestions, fetchTheirTurnQuestions, fetchEnabledPacks, refreshStreak };
}
describe('useCoupleSync', () => {
let refreshers: ReturnType<typeof stubRefreshers>;
const originals = {
auth: { fetchUser: useAuthStore.getState().fetchUser, fetchCouple: useAuthStore.getState().fetchCouple },
match: {
fetchMatches: useMatchStore.getState().fetchMatches,
fetchPendingQuestions: useMatchStore.getState().fetchPendingQuestions,
fetchTheirTurnQuestions: useMatchStore.getState().fetchTheirTurnQuestions,
},
packs: { fetchEnabledPacks: usePacksStore.getState().fetchEnabledPacks },
streak: { refreshStreak: useStreakStore.getState().refreshStreak },
};
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
mockForeground.value = true;
mockListeners.clear();
useMessageStore.setState({ unreadCount: 0 });
// Every view starts loaded: this hook must refresh only what is on screen,
// and the "not loaded" case is asserted separately below.
useMatchStore.setState({ loadedAt: { active: 1, pending: 1, their_turn: 1 } });
usePacksStore.setState({ enabledPacksLoadedAt: Date.now() });
refreshers = stubRefreshers();
});
afterEach(() => {
jest.useRealTimers();
useAuthStore.setState(originals.auth as never);
useMatchStore.setState({ ...originals.match, loadedAt: {} } as never);
usePacksStore.setState({ ...originals.packs, enabledPacksLoadedAt: null } as never);
useStreakStore.setState(originals.streak as never);
});
const answer = (...summaries: SyncSummary[]) => {
const mock = appDataApi.syncSummary as jest.Mock;
mock.mockReset();
for (const summary of summaries) mock.mockResolvedValueOnce(summary);
mock.mockResolvedValue(summaries[summaries.length - 1]);
};
it('spends one request per interval and refreshes nothing while the markers hold still', async () => {
answer(baseline);
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(1);
await tick();
await tick();
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(3);
// Three polls, three requests in total: the baseline poll must not trigger
// the refresh of five domains that the old five-second sweep performed.
for (const refresh of Object.values(refreshers)) expect(refresh).not.toHaveBeenCalled();
});
it('applies the unread total from the summary without a second request', async () => {
answer(baseline, { ...baseline, unread_total: 4 });
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
await tick();
expect(useMessageStore.getState().unreadCount).toBe(4);
});
it('refreshes only the domain whose marker moved', async () => {
answer(baseline, { ...baseline, latest_match_at: '2026-08-28T00:00:00.000Z', match_count: 4 });
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
await tick();
expect(refreshers.fetchMatches).toHaveBeenCalledWith(true, { silent: true });
expect(refreshers.fetchPendingQuestions).not.toHaveBeenCalled();
expect(refreshers.fetchTheirTurnQuestions).not.toHaveBeenCalled();
expect(refreshers.fetchEnabledPacks).not.toHaveBeenCalled();
expect(refreshers.fetchUser).not.toHaveBeenCalled();
expect(refreshers.fetchCouple).not.toHaveBeenCalled();
});
it('routes each remaining marker to its own domain', async () => {
answer(baseline, {
...baseline,
pending_yours: 2,
pending_theirs: 3,
enabled_packs_fingerprint: 'digest-b',
partner_updated_at: '2026-08-29T00:00:00.000Z',
streak_updated_at: '2026-08-29T00:00:00.000Z',
});
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
await tick();
expect(refreshers.fetchPendingQuestions).toHaveBeenCalledWith({ silent: true });
expect(refreshers.fetchTheirTurnQuestions).toHaveBeenCalledWith({ silent: true });
expect(refreshers.fetchEnabledPacks).toHaveBeenCalledTimes(1);
expect(refreshers.refreshStreak).toHaveBeenCalledTimes(1);
// A partner profile change is a couple-level read; the whole user is only
// re-read when the user's own profile or couple membership moved.
expect(refreshers.fetchCouple).toHaveBeenCalledTimes(1);
expect(refreshers.fetchUser).not.toHaveBeenCalled();
expect(refreshers.fetchMatches).not.toHaveBeenCalled();
});
it('refreshes a loaded Matches view when the match-state fingerprint moves in place', async () => {
// Counts and latest-created-at do not move for an in-place match_type/
// response_summary edit; only the state fingerprint does.
answer(baseline, { ...baseline, match_state_fingerprint: 'state-b' });
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
await tick();
expect(refreshers.fetchMatches).toHaveBeenCalledWith(true, { silent: true });
});
it('refreshes a loaded Matches view when unread redistributes without changing the total', async () => {
// One match is read while another receives a message: unread_total holds
// steady but the per-match fingerprint (and unread-first ordering) moves.
answer(baseline, { ...baseline, unread_total: baseline.unread_total, match_unread_fingerprint: 'unread-b' });
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
await tick();
expect(refreshers.fetchMatches).toHaveBeenCalledWith(true, { silent: true });
});
it('does not refresh enabled packs when that domain has never been opened', async () => {
usePacksStore.setState({ enabledPacksLoadedAt: null });
answer(baseline, { ...baseline, enabled_packs_fingerprint: 'digest-b' });
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
await tick();
expect(refreshers.fetchEnabledPacks).not.toHaveBeenCalled();
});
it('resets the baseline when the account identity changes, instead of diffing against the prior account', async () => {
answer({ ...baseline, match_count: 3 });
const { rerender } = renderHook(
({ userId, coupleId }: { userId: string; coupleId: string }) => useCoupleSync(userId, coupleId),
{ initialProps: { userId: 'alice', coupleId: 'couple-a' } },
);
await flush();
await tick(); // establishes alice's baseline (match_count: 3)
// Switch accounts: bob's couple has a completely different match_count.
// Without a baseline reset this would look like a 12-count jump and
// trigger a refresh purely from comparing across accounts.
answer({ ...baseline, couple_id: 'couple-b', match_count: 15 });
rerender({ userId: 'bob', coupleId: 'couple-b' });
await flush();
expect(refreshers.fetchMatches).not.toHaveBeenCalled();
await tick();
expect(refreshers.fetchMatches).not.toHaveBeenCalled();
});
it('re-reads the user once when the profile moves, instead of the couple as well', async () => {
answer(baseline, {
...baseline,
profile_updated_at: '2026-08-29T00:00:00.000Z',
partner_updated_at: '2026-08-29T00:00:00.000Z',
});
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
await tick();
expect(refreshers.fetchUser).toHaveBeenCalledTimes(1);
expect(refreshers.fetchCouple).not.toHaveBeenCalled();
});
it('leaves views the user has never opened unloaded', async () => {
useMatchStore.setState({ loadedAt: {} });
answer(baseline, {
...baseline,
match_count: 9,
latest_match_at: '2026-08-29T00:00:00.000Z',
pending_yours: 7,
pending_theirs: 8,
});
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
await tick();
expect(refreshers.fetchMatches).not.toHaveBeenCalled();
expect(refreshers.fetchPendingQuestions).not.toHaveBeenCalled();
expect(refreshers.fetchTheirTurnQuestions).not.toHaveBeenCalled();
});
it('stops entirely for a signed-out user and while the app is backgrounded', async () => {
answer(baseline);
const { unmount } = renderHook(() => useCoupleSync(undefined, null));
await flush();
await tick();
expect(appDataApi.syncSummary).not.toHaveBeenCalled();
unmount();
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(1);
await act(async () => {
mockForeground.value = false;
for (const listener of [...mockListeners]) listener(false);
});
await tick();
await tick();
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(1);
});
it('discards a summary that lands after the account it belongs to was switched away', async () => {
// Alice's poll is still open when Bob signs in. Its answer describes a
// couple Bob is not in, so none of it may land: not his badge, not the
// baseline his next poll is diffed against, not a refresh decision.
let answerAlice: (summary: SyncSummary) => void = () => undefined;
const mock = appDataApi.syncSummary as jest.Mock;
mock.mockReset();
mock.mockReturnValueOnce(new Promise<SyncSummary>(resolve => { answerAlice = resolve; }));
const { rerender } = renderHook(
({ userId, coupleId }: { userId: string; coupleId: string }) => useCoupleSync(userId, coupleId),
{ initialProps: { userId: 'alice', coupleId: 'couple-a' } },
);
await flush();
expect(mock).toHaveBeenCalledTimes(1);
const bobBaseline: SyncSummary = { ...baseline, couple_id: 'couple-b', unread_total: 1, match_count: 2 };
mock.mockResolvedValue(bobBaseline);
rerender({ userId: 'bob', coupleId: 'couple-b' });
await flush();
expect(useMessageStore.getState().unreadCount).toBe(1);
// Alice's request finally answers, long after she stopped being the user.
await act(async () => {
answerAlice({ ...baseline, couple_id: 'couple-a', unread_total: 9, match_count: 42 });
await Promise.resolve();
});
await flush();
expect(useMessageStore.getState().unreadCount).toBe(1);
// Bob's baseline is still Bob's: a poll identical to his own first one has
// nothing to refresh. Had Alice's answer been recorded as the baseline,
// this would read as a 40-match jump and refresh the Matches view.
await tick();
expect(useMessageStore.getState().unreadCount).toBe(1);
for (const refresh of Object.values(refreshers)) expect(refresh).not.toHaveBeenCalled();
});
it('never runs two summaries at once', async () => {
let release: (summary: SyncSummary) => void = () => undefined;
(appDataApi.syncSummary as jest.Mock).mockReset();
(appDataApi.syncSummary as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
(appDataApi.syncSummary as jest.Mock).mockResolvedValue(baseline);
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
await act(async () => { jest.advanceTimersByTime(SYNC_INTERVAL_MS * 4); });
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(1);
await act(async () => { release(baseline); await Promise.resolve(); });
await tick();
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(2);
});
});
@@ -1,6 +1,7 @@
import { act, renderHook, waitFor } from "@testing-library/react-native";
import { useDares } from "@/features/dares/hooks/useDares";
import { ApiError, apiClient } from "@/lib/apiClient";
import type { UseDaresReturn } from "@/features/dares/hooks/useDares";
import type { SentDare } from "@/features/dares/types";
const incoming: SentDare = {
@@ -90,3 +91,80 @@ describe("useDares", () => {
expect(result.current.error).toBe("offline");
});
});
describe("useDares polling cost", () => {
/** Counts requests per endpoint and lets the active payload be swapped mid-test. */
function mockCounting(active: () => SentDare[]) {
const calls = { active: 0, history: 0, catalog: 0, stats: 0 };
jest.spyOn(apiClient, "get").mockImplementation(async (path: string) => {
if (path.startsWith("/v1/dares?filter=active")) { calls.active += 1; return { dares: active() } as never; }
if (path.startsWith("/v1/dares?filter=history")) { calls.history += 1; return { dares: [] } as never; }
if (path === "/v1/dares/packs") { calls.catalog += 1; return catalog as never; }
if (path === "/v1/dares/stats") { calls.stats += 1; return stats as never; }
throw new Error(`unexpected path ${path}`);
});
return calls;
}
const flush = async () => { await act(async () => { await Promise.resolve(); await Promise.resolve(); }); };
const tick = async () => {
await flush();
await act(async () => { jest.advanceTimersByTime(5_000); });
await flush();
};
beforeEach(() => { jest.restoreAllMocks(); jest.useFakeTimers(); });
afterEach(() => jest.useRealTimers());
it("polls the active list alone and re-reads the static endpoints only when it changes", async () => {
let current: SentDare[] = [incoming];
const calls = mockCounting(() => current);
const { result } = renderHook(() => useDares({ isFocused: true }));
await flush();
expect(result.current.loading).toBe(false);
// The first pass is a full load: nothing is cached yet.
expect(calls).toEqual({ active: 1, history: 1, catalog: 1, stats: 1 });
await tick();
await tick();
// Two quiet polls cost one request each, not four. The catalogue and the
// stats cannot move while the active set is byte-for-byte identical.
expect(calls).toEqual({ active: 3, history: 1, catalog: 1, stats: 1 });
current = [{ ...incoming, status: "active" }];
await tick();
expect(calls).toEqual({ active: 4, history: 2, catalog: 2, stats: 2 });
expect(result.current.active[0]?.status).toBe("active");
await tick();
expect(calls).toEqual({ active: 5, history: 2, catalog: 2, stats: 2 });
});
it("issues nothing at all while the screen is not on top", async () => {
const calls = mockCounting(() => [incoming]);
const { rerender } = renderHook<UseDaresReturn, { isFocused: boolean }>(
({ isFocused }) => useDares({ isFocused }),
{ initialProps: { isFocused: false } },
);
await tick();
await tick();
expect(calls).toEqual({ active: 0, history: 0, catalog: 0, stats: 0 });
rerender({ isFocused: true });
await flush();
expect(calls).toEqual({ active: 1, history: 1, catalog: 1, stats: 1 });
});
it("never overlaps two polls", async () => {
let release: (payload: { dares: SentDare[] }) => void = () => undefined;
jest.spyOn(apiClient, "get").mockImplementation(async (path: string) => {
if (path.startsWith("/v1/dares?filter=active")) return new Promise(resolve => { release = resolve; }) as never;
return { dares: [], ...catalog, ...stats } as never;
});
renderHook(() => useDares({ isFocused: true }));
await flush();
await act(async () => { jest.advanceTimersByTime(20_000); });
expect((apiClient.get as jest.Mock).mock.calls.filter(([path]) => String(path).includes("filter=active"))).toHaveLength(1);
await act(async () => { release({ dares: [] }); await Promise.resolve(); });
});
});
@@ -48,6 +48,60 @@ describe('useDrawingSync API polling', () => {
});
});
describe('useDrawingSync focus gating', () => {
const handlers = {
onStrokeStart: jest.fn(), onStrokeContinue: jest.fn(), onStrokeEnd: jest.fn(),
onClearCanvas: jest.fn(), onUndo: jest.fn(), onRedo: jest.fn(),
};
beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); });
afterEach(() => jest.useRealTimers());
it('reads the canvas once on mount and then polls only while the screen is on top', async () => {
(appDataApi.getLiveDraw as jest.Mock).mockResolvedValue({ strokes: [], revision: 1, updated_at: 'one', updated_by: userId });
const onLoadStrokes = jest.fn();
const { rerender } = renderHook<unknown, { isFocused: boolean }>(({ isFocused }) => useDrawingSync({
coupleId: 'couple', userId, onLoadStrokes, isFocused, ...handlers,
}), { initialProps: { isFocused: false } });
await waitFor(() => expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(1));
// A canvas nobody is looking at costs nothing beyond the state it loaded with.
await act(async () => { jest.advanceTimersByTime(750 * 8); await Promise.resolve(); });
expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(1);
rerender({ isFocused: true });
await act(async () => { jest.advanceTimersByTime(750); await Promise.resolve(); });
expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(2);
rerender({ isFocused: false });
await act(async () => { jest.advanceTimersByTime(750 * 8); await Promise.resolve(); });
expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(2);
});
it('never polls over its own write in flight', async () => {
(appDataApi.getLiveDraw as jest.Mock).mockResolvedValue({ strokes: [], revision: 1, updated_at: 'one', updated_by: userId });
let release: (state: unknown) => void = () => undefined;
(appDataApi.putLiveDraw as jest.Mock).mockReturnValue(new Promise(resolve => { release = resolve; }));
const onLoadStrokes = jest.fn();
const { result } = renderHook(() => useDrawingSync({
coupleId: 'couple', userId, onLoadStrokes, isFocused: true, ...handlers,
}));
await waitFor(() => expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(1));
const pending = result.current.persistStrokes([stroke]);
await act(async () => { jest.advanceTimersByTime(750 * 4); await Promise.resolve(); });
// The write returns the authoritative state; reading over the top of it would
// only race a revision we are about to replace.
expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(1);
await act(async () => {
release({ strokes: [stroke], revision: 2, updated_at: 'two', updated_by: userId });
await pending;
});
await act(async () => { jest.advanceTimersByTime(750); await Promise.resolve(); });
expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(2);
});
});
describe('useDrawingSync stroke deduplication', () => {
const at = (id: string, points: number, userId = 'u1') => ({
id, userId, points: Array.from({ length: points }, (_, i) => ({ x: i, y: i })),
@@ -4,85 +4,183 @@ import { chatApi } from '@/lib/chatApi';
jest.mock('@/lib/chatApi', () => ({ chatApi: { listMessages: jest.fn(), markRead: jest.fn() } }));
const message = (id: string, user_id = 'partner') => ({ id, match_id: 'match1', user_id, content: id } as any);
const message = (id: string, user_id = 'partner', overrides: Record<string, unknown> = {}) => ({
id, match_id: 'match1', user_id, content: id,
created_at: '2026-08-27T12:00:00Z', read_at: null, ...overrides,
} as any);
describe('useMessageSubscription API polling', () => {
const snapshot = (messages: any[], server_time = 'cursor-1') => ({ messages, removed_ids: [], server_time, complete: true });
const delta = (messages: any[], options: { removed?: string[]; server_time?: string; typing?: boolean } = {}) => ({
messages,
removed_ids: options.removed ?? [],
server_time: options.server_time ?? 'cursor-2',
complete: false,
...(options.typing === undefined ? {} : { typing: { typing: options.typing, expires_at: null } }),
});
const listMessages = chatApi.listMessages as jest.Mock;
const markRead = chatApi.markRead as jest.Mock;
describe('useMessageSubscription incremental polling', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
(chatApi.listMessages as jest.Mock).mockResolvedValue({ messages: [] });
(chatApi.markRead as jest.Mock).mockResolvedValue({ updated: 0, read_at: 'now' });
listMessages.mockResolvedValue(snapshot([]));
markRead.mockResolvedValue({ updated: 0, read_at: 'now' });
});
afterEach(() => jest.useRealTimers());
it('fails closed without both a match and authenticated user', async () => {
const { result, rerender } = renderHook<ReturnType<typeof useMessageSubscription>, { matchId: string | undefined; userId: string | undefined }>(
const mount = (config: Partial<Parameters<typeof useMessageSubscription>[0]> = {}) =>
renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', pollInterval: 500, ...config }));
it('fails closed without both match and authenticated user', () => {
const { rerender } = renderHook<ReturnType<typeof useMessageSubscription>, { matchId: string | undefined; userId: string | undefined }>(
({ matchId, userId }) => useMessageSubscription({ matchId, userId }),
{ initialProps: { matchId: undefined as string | undefined, userId: 'me' as string | undefined } },
);
expect(result.current.loading).toBe(false);
expect(chatApi.listMessages).not.toHaveBeenCalled();
expect(listMessages).not.toHaveBeenCalled();
rerender({ matchId: 'match1', userId: undefined });
expect(chatApi.listMessages).not.toHaveBeenCalled();
expect(listMessages).not.toHaveBeenCalled();
});
it('loads initial messages without treating them as newly arrived', async () => {
it('loads a snapshot first, then requests only changes since the server cursor', async () => {
listMessages.mockResolvedValueOnce(snapshot([message('m1')], 'cursor-1'));
const { result } = mount();
await waitFor(() => expect(result.current.loading).toBe(false));
expect(listMessages).toHaveBeenNthCalledWith(1, 'match1', { typing: true });
await act(async () => { jest.advanceTimersByTime(500); });
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(2));
expect(listMessages).toHaveBeenNthCalledWith(2, 'match1', { since: 'cursor-1', typing: true });
});
it('keeps cached messages, and their identity, when a delta carries nothing', async () => {
listMessages.mockResolvedValueOnce(snapshot([message('m1', 'partner', { read_at: 'seen' })]));
listMessages.mockResolvedValue(delta([]));
const { result } = mount();
await waitFor(() => expect(result.current.messages).toHaveLength(1));
const cached = result.current.messages;
await act(async () => { jest.advanceTimersByTime(500); });
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(2));
expect(result.current.messages).toBe(cached);
expect(result.current.loading).toBe(false);
});
it('merges delta rows by id, applies self-deletions, and keeps newest first', async () => {
listMessages.mockResolvedValueOnce(snapshot([
message('m2', 'me', { created_at: '2026-08-27T12:00:02Z' }),
message('m1', 'partner', { created_at: '2026-08-27T12:00:01Z', read_at: 'seen' }),
]));
listMessages.mockResolvedValueOnce(delta([
message('m3', 'partner', { created_at: '2026-08-27T12:00:03Z', read_at: 'seen' }),
message('m1', 'partner', { created_at: '2026-08-27T12:00:01Z', read_at: 'seen', deleted_at: 'gone' }),
], { removed: ['m2'] }));
const { result } = mount();
await waitFor(() => expect(result.current.messages).toHaveLength(2));
await act(async () => { jest.advanceTimersByTime(500); });
await waitFor(() => expect(result.current.messages).toHaveLength(2));
expect(result.current.messages.map(m => m.id)).toEqual(['m3', 'm1']);
expect(result.current.messages[1]?.deleted_at).toBe('gone');
});
it('marks read only when the response holds an unread partner message', async () => {
listMessages.mockResolvedValueOnce(snapshot([message('m1', 'partner')]));
listMessages.mockResolvedValueOnce(delta([message('m2', 'me')]));
listMessages.mockResolvedValueOnce(delta([message('m1', 'partner', { read_at: 'seen' })]));
const { result } = mount();
await waitFor(() => expect(markRead).toHaveBeenCalledTimes(1));
await act(async () => { jest.advanceTimersByTime(500); });
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(2));
await act(async () => { jest.advanceTimersByTime(500); });
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(3));
expect(markRead).toHaveBeenCalledTimes(1);
expect(result.current.messages).toHaveLength(2);
});
it('issues no request at all while the chat is not focused', async () => {
const { rerender } = renderHook<ReturnType<typeof useMessageSubscription>, { focused: boolean }>(
({ focused }) => useMessageSubscription({ matchId: 'match1', userId: 'me', pollInterval: 500, isFocused: focused }),
{ initialProps: { focused: false } },
);
await act(async () => { jest.advanceTimersByTime(2_000); });
expect(listMessages).not.toHaveBeenCalled();
expect(markRead).not.toHaveBeenCalled();
rerender({ focused: true });
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(1));
rerender({ focused: false });
const callsAtBlur = listMessages.mock.calls.length;
await act(async () => { jest.advanceTimersByTime(2_000); });
expect(listMessages).toHaveBeenCalledTimes(callsAtBlur);
});
it('reports partner arrivals once, and never for the initial load or own messages', async () => {
const onNewMessage = jest.fn();
(chatApi.listMessages as jest.Mock).mockResolvedValue({ messages: [message('m1')] });
const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', onNewMessage }));
listMessages.mockResolvedValueOnce(snapshot([message('m1', 'partner', { read_at: 'seen' })]));
listMessages.mockResolvedValueOnce(delta([message('mine', 'me')]));
listMessages.mockResolvedValueOnce(delta([message('m2', 'partner')]));
listMessages.mockResolvedValueOnce(delta([message('m2', 'partner', { read_at: 'seen' })]));
const { result } = mount({ onNewMessage });
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.messages).toEqual([message('m1')]);
expect(onNewMessage).not.toHaveBeenCalled();
expect(chatApi.markRead).not.toHaveBeenCalled();
});
it('refreshes on the polling interval, marks focused messages read, and reports new partner messages', async () => {
const onNewMessage = jest.fn();
(chatApi.listMessages as jest.Mock)
.mockResolvedValueOnce({ messages: [message('m1')] })
.mockResolvedValueOnce({ messages: [message('m2'), message('m1')] });
const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', onNewMessage, pollInterval: 500 }));
await waitFor(() => expect(result.current.loading).toBe(false));
act(() => { result.current.isFocusedRef.current = true; });
await act(async () => { jest.advanceTimersByTime(500); await Promise.resolve(); });
await waitFor(() => expect(result.current.messages[0]?.id).toBe('m2'));
expect(chatApi.markRead).toHaveBeenCalledWith('match1');
for (const expected of [2, 3, 4]) {
await act(async () => { jest.advanceTimersByTime(500); });
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(expected));
}
expect(onNewMessage).toHaveBeenCalledTimes(1);
});
it('does not notify for the current user own newly polled message', async () => {
const onNewMessage = jest.fn();
(chatApi.listMessages as jest.Mock)
.mockResolvedValueOnce({ messages: [] })
.mockResolvedValueOnce({ messages: [message('mine', 'me')] });
const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', onNewMessage, pollInterval: 500 }));
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => { jest.advanceTimersByTime(500); await Promise.resolve(); });
expect(onNewMessage).not.toHaveBeenCalled();
it('reads partner typing state out of the message response', async () => {
listMessages.mockResolvedValueOnce({ ...snapshot([]), typing: { typing: true, expires_at: 'later' } });
const { result } = mount();
await waitFor(() => expect(result.current.partnerTyping).toBe(true));
listMessages.mockResolvedValueOnce(delta([], { typing: false }));
await act(async () => { jest.advanceTimersByTime(500); });
await waitFor(() => expect(result.current.partnerTyping).toBe(false));
});
it('surfaces loading completion and preserves a usable empty state after an API error', async () => {
(chatApi.listMessages as jest.Mock).mockRejectedValueOnce(new Error('forbidden'));
const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me' }));
listMessages.mockRejectedValueOnce(new Error('forbidden'));
const { result } = mount();
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.messages).toEqual([]);
});
it('prevents overlapping polls and clears the interval on unmount', async () => {
let resolveFirst!: (value: { messages: any[] }) => void;
(chatApi.listMessages as jest.Mock).mockImplementationOnce(() => new Promise(resolve => { resolveFirst = resolve; }));
const { unmount } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', pollInterval: 500 }));
expect(chatApi.listMessages).toHaveBeenCalledTimes(1);
act(() => { jest.advanceTimersByTime(1_500); });
expect(chatApi.listMessages).toHaveBeenCalledTimes(1);
await act(async () => resolveFirst({ messages: [] }));
it('prevents overlapping polls and stops on unmount', async () => {
let resolveFirst!: (value: unknown) => void;
listMessages.mockImplementationOnce(() => new Promise(resolve => { resolveFirst = resolve; }));
const { unmount } = mount();
expect(listMessages).toHaveBeenCalledTimes(1);
await act(async () => { jest.advanceTimersByTime(1_500); });
expect(listMessages).toHaveBeenCalledTimes(1);
await act(async () => { resolveFirst(snapshot([])); });
unmount();
act(() => { jest.advanceTimersByTime(2_000); });
expect(chatApi.listMessages).toHaveBeenCalledTimes(1);
await act(async () => { jest.advanceTimersByTime(2_000); });
expect(listMessages).toHaveBeenCalledTimes(1);
});
it('restarts immediately, and without a stale cursor, for a different match', async () => {
listMessages.mockResolvedValue(snapshot([message('m1')], 'cursor-1'));
const { result, rerender } = renderHook<ReturnType<typeof useMessageSubscription>, { matchId: string }>(
({ matchId }) => useMessageSubscription({ matchId, userId: 'me', pollInterval: 500 }),
{ initialProps: { matchId: 'match1' } },
);
await waitFor(() => expect(result.current.messages).toHaveLength(1));
rerender({ matchId: 'match2' });
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(2));
expect(listMessages).toHaveBeenNthCalledWith(2, 'match2', { typing: true });
});
it('supports optimistic external updates', async () => {
const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me' }));
const { result } = mount();
await waitFor(() => expect(result.current.loading).toBe(false));
act(() => result.current.setMessages([message('optimistic', 'me')]));
expect(result.current.messages[0]?.id).toBe('optimistic');
@@ -0,0 +1,141 @@
import { act, renderHook } from '@testing-library/react-native';
import { usePolling } from '@/hooks/usePolling';
const mockForeground = { value: true };
const mockListeners = new Set<(foreground: boolean) => void>();
jest.mock('@/lib/appForeground', () => ({
isForeground: () => mockForeground.value,
subscribeToForeground: (listener: (foreground: boolean) => void) => {
mockListeners.add(listener);
return () => { mockListeners.delete(listener); };
},
}));
const setForeground = async (value: boolean) => {
await act(async () => {
mockForeground.value = value;
for (const listener of [...mockListeners]) listener(value);
});
};
// The next run is scheduled once the previous one settles, so let pending
// promises land before and after moving the clock.
const flush = async () => { await act(async () => { await Promise.resolve(); await Promise.resolve(); }); };
const tick = async (ms: number) => {
await flush();
await act(async () => { jest.advanceTimersByTime(ms); });
await flush();
};
describe('usePolling', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
mockForeground.value = true;
mockListeners.clear();
});
afterEach(() => jest.useRealTimers());
it('runs once immediately and then on the interval', async () => {
const task = jest.fn(async () => undefined);
renderHook(() => usePolling(task, { intervalMs: 100 }));
expect(task).toHaveBeenCalledTimes(1);
await tick(100);
expect(task).toHaveBeenCalledTimes(2);
await tick(100);
expect(task).toHaveBeenCalledTimes(3);
});
it('skips the leading run when asked to', async () => {
const task = jest.fn(async () => undefined);
renderHook(() => usePolling(task, { intervalMs: 100, leading: false }));
expect(task).not.toHaveBeenCalled();
await tick(100);
expect(task).toHaveBeenCalledTimes(1);
});
it('issues nothing while disabled and starts once enabled', async () => {
const task = jest.fn(async () => undefined);
const { rerender } = renderHook<void, { enabled: boolean }>(({ enabled }) => usePolling(task, { intervalMs: 100, enabled }), {
initialProps: { enabled: false },
});
await tick(1_000);
expect(task).not.toHaveBeenCalled();
rerender({ enabled: true });
expect(task).toHaveBeenCalledTimes(1);
});
it('stops while the app is backgrounded and catches up on return', async () => {
const task = jest.fn(async () => undefined);
renderHook(() => usePolling(task, { intervalMs: 100 }));
expect(task).toHaveBeenCalledTimes(1);
await setForeground(false);
await tick(1_000);
expect(task).toHaveBeenCalledTimes(1);
await setForeground(true);
expect(task).toHaveBeenCalledTimes(2);
await tick(100);
expect(task).toHaveBeenCalledTimes(3);
});
it('never runs two tasks concurrently', async () => {
let release!: () => void;
const task = jest.fn(() => new Promise<void>(resolve => { release = resolve; }));
renderHook(() => usePolling(task, { intervalMs: 100 }));
expect(task).toHaveBeenCalledTimes(1);
await tick(500);
expect(task).toHaveBeenCalledTimes(1);
await act(async () => { release(); });
await tick(100);
expect(task).toHaveBeenCalledTimes(2);
});
it('backs off after consecutive failures and recovers after a success', async () => {
const task = jest.fn(async (): Promise<void> => { throw new Error('offline'); });
renderHook(() => usePolling(task, { intervalMs: 100, maxIntervalMs: 400 }));
expect(task).toHaveBeenCalledTimes(1);
await tick(100);
expect(task).toHaveBeenCalledTimes(1);
await tick(100);
expect(task).toHaveBeenCalledTimes(2);
await tick(399);
expect(task).toHaveBeenCalledTimes(2);
await tick(1);
expect(task).toHaveBeenCalledTimes(3);
task.mockImplementation(async () => undefined);
await tick(400);
expect(task).toHaveBeenCalledTimes(4);
await tick(100);
expect(task).toHaveBeenCalledTimes(5);
});
it('restarts immediately when the reset key changes', async () => {
const task = jest.fn(async () => undefined);
const { rerender } = renderHook<void, { resetKey: string }>(({ resetKey }) => usePolling(task, { intervalMs: 100, resetKey }), {
initialProps: { resetKey: 'a' },
});
expect(task).toHaveBeenCalledTimes(1);
rerender({ resetKey: 'b' });
expect(task).toHaveBeenCalledTimes(2);
await tick(50);
expect(task).toHaveBeenCalledTimes(2);
});
it('stops and unsubscribes on unmount', async () => {
const task = jest.fn(async () => undefined);
const { unmount } = renderHook(() => usePolling(task, { intervalMs: 100 }));
await tick(100);
expect(task).toHaveBeenCalledTimes(2);
unmount();
expect(mockListeners.size).toBe(0);
await tick(1_000);
expect(task).toHaveBeenCalledTimes(2);
});
});
@@ -76,4 +76,22 @@ describe('useTypingIndicator API polling', () => {
act(() => { jest.advanceTimersByTime(2_000); });
expect(chatApi.getTyping).toHaveBeenCalledTimes(calls);
});
it('stops polling and mirrors typing state supplied by the message poll', async () => {
const { result, rerender } = renderHook<ReturnType<typeof useTypingIndicator>, { externalTyping: boolean | undefined }>(
({ externalTyping }) => useTypingIndicator({ channelName: 'chat:match1', userId: 'me', pollInterval: 500, externalTyping }),
{ initialProps: { externalTyping: undefined as boolean | undefined } },
);
await act(async () => { await Promise.resolve(); });
const callsBeforeFolding = (chatApi.getTyping as jest.Mock).mock.calls.length;
rerender({ externalTyping: true });
expect(result.current.partnerTyping).toBe(true);
await act(async () => { jest.advanceTimersByTime(5_000); });
expect(chatApi.getTyping).toHaveBeenCalledTimes(callsBeforeFolding);
act(() => result.current.clearTypingIndicator());
expect(result.current.partnerTyping).toBe(false);
rerender({ externalTyping: false });
expect(result.current.partnerTyping).toBe(false);
});
});
@@ -0,0 +1,36 @@
import { AppState } from 'react-native';
import { isForeground, isForegroundState, subscribeToForeground } from '@/lib/appForeground';
describe('appForeground', () => {
afterEach(() => jest.restoreAllMocks());
it('treats only background and inactive as away', () => {
expect(isForegroundState('active')).toBe(true);
expect(isForegroundState('background')).toBe(false);
expect(isForegroundState('inactive')).toBe(false);
// Unknown states — including whatever a test harness leaves behind — must
// fail towards running, never towards disabling recurring work forever.
expect(isForegroundState(undefined)).toBe(true);
expect(isForegroundState('extension')).toBe(true);
});
it('reads the current state and reports transitions until unsubscribed', () => {
let emit: (state: string) => void = () => undefined;
const remove = jest.fn();
jest.spyOn(AppState, 'addEventListener').mockImplementation(((_: string, handler: (state: string) => void) => {
emit = handler;
return { remove } as never;
}) as never);
expect(isForeground()).toBe(isForegroundState(AppState.currentState));
const seen: boolean[] = [];
const unsubscribe = subscribeToForeground(value => seen.push(value));
emit('background');
emit('active');
expect(seen).toEqual([false, true]);
unsubscribe();
expect(remove).toHaveBeenCalled();
});
});
@@ -24,6 +24,18 @@ describe('chatApi', () => {
expect(apiClient.get).toHaveBeenNthCalledWith(2, '/v1/matches/match%2Fid/typing');
});
it('encodes the delta cursor and folded typing request into the message query', async () => {
(apiClient.get as jest.Mock).mockResolvedValue({ messages: [], removed_ids: [], server_time: 'now', complete: false });
await chatApi.listMessages('match1', { typing: true });
await chatApi.listMessages('match1', { since: '2026-08-27T12:00:00.000+01:00' });
await chatApi.listMessages('match1', { since: '2026-08-27T12:00:00.000Z', typing: true });
expect(apiClient.get).toHaveBeenNthCalledWith(1, '/v1/matches/match1/messages?typing=true');
expect(apiClient.get).toHaveBeenNthCalledWith(2, '/v1/matches/match1/messages?since=2026-08-27T12%3A00%3A00.000%2B01%3A00');
expect(apiClient.get).toHaveBeenNthCalledWith(3, '/v1/matches/match1/messages?since=2026-08-27T12%3A00%3A00.000Z&typing=true');
});
it('uses authenticated unread, receipt, deletion and reporting routes', async () => {
(apiClient.get as jest.Mock).mockResolvedValue({ total: 0, by_match: {} });
(apiClient.post as jest.Mock).mockResolvedValue({});
@@ -45,9 +45,18 @@ describe('matchStore API behavior', () => {
});
it('guards concurrent loads and exhausted pagination', async () => {
useMatchStore.setState({ isLoading: true });
await useMatchStore.getState().fetchMatches(true);
useMatchStore.setState({ isLoading: false, hasMore: false });
// Overlap protection cannot key off the loading flags any more: a silent
// refresh raises none of them and still must not issue a second request.
let release: (page: { matches: unknown[]; totalCount: number }) => void = () => undefined;
(apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const inFlight = useMatchStore.getState().fetchMatches(true);
await useMatchStore.getState().fetchMatches(true, { silent: true });
expect(apiClient.get).toHaveBeenCalledTimes(1);
release({ matches: [], totalCount: 0 });
await inFlight;
(apiClient.get as jest.Mock).mockClear();
useMatchStore.setState({ hasMore: false });
await useMatchStore.getState().fetchMatches(false);
expect(apiClient.get).not.toHaveBeenCalled();
});
@@ -147,7 +156,10 @@ describe('matchStore API behavior', () => {
it('toggles archived visibility and triggers an initial fetch only', async () => {
(apiClient.get as jest.Mock).mockResolvedValue({ matches: [], totalCount: 0 });
useMatchStore.getState().toggleShowArchived();
await Promise.resolve();
// Let the floating fetch settle: its in-flight guard is module-scoped and
// is only released in a finally, so a half-run request would leak into
// the next test and silently suppress its archived fetch.
await jest.runOnlyPendingTimersAsync();
expect(useMatchStore.getState().showArchived).toBe(true);
expect(apiClient.get).toHaveBeenCalledTimes(1);
useMatchStore.getState().toggleShowArchived();
@@ -178,11 +190,19 @@ describe('matchStore API behavior', () => {
});
it('updates current view and delegates an empty view fetch', () => {
const real = useMatchStore.getState().fetchArchivedMatches;
const fetchArchivedMatches = jest.fn(async () => undefined);
useMatchStore.setState({ fetchArchivedMatches });
useMatchStore.getState().setCurrentView('archived');
expect(useMatchStore.getState()).toMatchObject({ currentView: 'archived', showArchived: true });
expect(fetchArchivedMatches).toHaveBeenCalled();
try {
useMatchStore.getState().setCurrentView('archived');
expect(useMatchStore.getState()).toMatchObject({ currentView: 'archived', showArchived: true });
expect(fetchArchivedMatches).toHaveBeenCalled();
} finally {
// Actions live in the same store as the data, and clearMatches does not
// reset them: leaving the stub in place would silence every later test's
// archived fetch.
useMatchStore.setState({ fetchArchivedMatches: real });
}
});
});
@@ -225,6 +245,222 @@ describe('matchStore API behavior', () => {
});
});
describe('view switching and cache freshness', () => {
/** Answers each of the four view endpoints with an empty payload. */
const emptyViews = () => (apiClient.get as jest.Mock).mockImplementation(async (path: string) => {
if (path.startsWith('/v1/matches?archived=true')) return { matches: [], totalCount: 0 };
if (path.startsWith('/v1/matches')) return { matches: [], totalCount: 0, hasMore: false };
return { questions: [] };
});
it('switches between loaded views without a single extra request, even when every view is empty', async () => {
emptyViews();
const views = ['pending', 'their_turn', 'active', 'archived'] as const;
// First visit to each view loads it once.
for (const view of views) {
useMatchStore.getState().setCurrentView(view);
await jest.runOnlyPendingTimersAsync();
}
expect(apiClient.get).toHaveBeenCalledTimes(views.length);
expect(useMatchStore.getState()).toMatchObject({
matches: [], pendingQuestions: [], theirTurnQuestions: [], archivedMatches: [],
});
// Every subsequent switch is served from cache. An empty view is loaded,
// not unloaded: this is the bug where `array.length === 0` meant "never
// fetched" and made every visit pay for the same empty answer.
(apiClient.get as jest.Mock).mockClear();
for (const view of [...views, ...views].reverse()) {
useMatchStore.getState().setCurrentView(view);
await jest.runOnlyPendingTimersAsync();
}
expect(apiClient.get).not.toHaveBeenCalled();
});
it('keeps cached rows and raises no foreground spinner during a silent refresh', async () => {
(apiClient.get as jest.Mock).mockResolvedValueOnce({ matches: [match('cached', false)], totalCount: 1, hasMore: false });
await useMatchStore.getState().fetchMatches(true);
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['cached']);
let release: (page: unknown) => void = () => undefined;
(apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const refreshing = useMatchStore.getState().fetchMatches(true, { silent: true });
// Mid-flight: the user still sees the rows they had, and neither the
// full-screen loader nor the pull-to-refresh spinner is showing.
expect(useMatchStore.getState()).toMatchObject({ isLoading: false, isRefreshing: false });
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['cached']);
release({ matches: [match('fresh', false)], totalCount: 1, hasMore: false });
await refreshing;
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['fresh']);
});
it('shows the pull-to-refresh spinner for a user-initiated refresh but never the initial loader', async () => {
(apiClient.get as jest.Mock).mockResolvedValueOnce({ matches: [match('cached', false)], totalCount: 1, hasMore: false });
await useMatchStore.getState().fetchMatches(true);
let release: (page: unknown) => void = () => undefined;
(apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const refreshing = useMatchStore.getState().fetchMatches(true);
expect(useMatchStore.getState()).toMatchObject({ isLoading: false, isRefreshing: true });
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['cached']);
release({ matches: [], totalCount: 0, hasMore: false });
await refreshing;
expect(useMatchStore.getState()).toMatchObject({ isRefreshing: false });
});
it('keeps a silent list refresh out of the pending and their-turn loaders', async () => {
(apiClient.get as jest.Mock).mockResolvedValue({ questions: [pending('p1')] });
await useMatchStore.getState().fetchPendingQuestions();
await useMatchStore.getState().fetchTheirTurnQuestions();
let release: (page: unknown) => void = () => undefined;
(apiClient.get as jest.Mock).mockReturnValue(new Promise(resolve => { release = resolve; }));
const quiet = Promise.all([
useMatchStore.getState().fetchPendingQuestions({ silent: true }),
useMatchStore.getState().fetchTheirTurnQuestions({ silent: true }),
]);
expect(useMatchStore.getState()).toMatchObject({ isLoadingPending: false, isLoadingTheirTurn: false });
expect(useMatchStore.getState().pendingQuestions).toHaveLength(1);
release({ questions: [] });
await quiet;
});
});
describe('account isolation (generation tokens)', () => {
it('does not let a stale fetchMatches response populate the store after sign-out, and lets the next account start immediately', async () => {
let release: (page: unknown) => void = () => undefined;
(apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const staleFetch = useMatchStore.getState().fetchMatches(true);
// Simulates sign-out/account switch while the request is still in flight.
useMatchStore.getState().clearMatches();
release({ matches: [match('stale')], totalCount: 1, hasMore: false });
await staleFetch;
expect(useMatchStore.getState().matches).toEqual([]);
(apiClient.get as jest.Mock).mockResolvedValueOnce({ matches: [match('fresh')], totalCount: 1, hasMore: false });
await useMatchStore.getState().fetchMatches(true);
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['fresh']);
});
it('does not let a stale seen-marker write over the next account\'s list', async () => {
useMatchStore.setState({ matches: [match('theirs')], newMatchesCount: 1 });
let release: () => void = () => undefined;
(apiClient.patch as jest.Mock).mockReturnValueOnce(new Promise<void>(resolve => { release = () => resolve(); }));
const stale = useMatchStore.getState().markAsSeen('theirs');
useMatchStore.getState().clearMatches();
useMatchStore.setState({ matches: [match('mine')], newMatchesCount: 1 });
const untouched = useMatchStore.getState().matches;
release();
await stale;
// Not merely "the same ids": the previous account's response must not
// rewrite the array at all, because the recount it derives is only true
// of the rows that account had loaded.
expect(useMatchStore.getState().matches).toBe(untouched);
expect(useMatchStore.getState().newMatchesCount).toBe(1);
});
it('does not let a stale mark-all-seen clear the next account\'s new badges', async () => {
useMatchStore.setState({ matches: [match('theirs')], newMatchesCount: 1 });
let release: () => void = () => undefined;
(apiClient.patch as jest.Mock).mockReturnValueOnce(new Promise<void>(resolve => { release = () => resolve(); }));
const stale = useMatchStore.getState().markAllAsSeen();
useMatchStore.getState().clearMatches();
useMatchStore.setState({ matches: [match('mine', true), match('mine2', true)], newMatchesCount: 2 });
release();
await stale;
expect(useMatchStore.getState().matches.every(item => item.is_new)).toBe(true);
expect(useMatchStore.getState().newMatchesCount).toBe(2);
});
it('does not roll a failed archive or unarchive back into the next account', async () => {
jest.spyOn(console, 'error').mockImplementation(() => undefined);
let fail: (error: Error) => void = () => undefined;
(apiClient.put as jest.Mock).mockReturnValueOnce(new Promise((_resolve, reject) => { fail = reject; }));
useMatchStore.setState({ matches: [match('theirs')], archivedMatches: [], archivedMatchIds: new Set<string>(), totalCount: 1 });
const staleArchive = useMatchStore.getState().archiveMatch('theirs');
useMatchStore.getState().clearMatches();
useMatchStore.setState({ matches: [match('mine')], totalCount: 1 });
fail(new Error('forbidden'));
await staleArchive;
// The revert would otherwise put one couple's match into another's list.
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['mine']);
expect(useMatchStore.getState().totalCount).toBe(1);
(apiClient.put as jest.Mock).mockReturnValueOnce(new Promise((_resolve, reject) => { fail = reject; }));
useMatchStore.setState({ archivedMatches: [match('theirsArchived')], archivedMatchIds: new Set(['theirsArchived']) });
const staleUnarchive = useMatchStore.getState().unarchiveMatch('theirsArchived');
useMatchStore.getState().clearMatches();
useMatchStore.setState({ archivedMatches: [match('mineArchived')], archivedMatchIds: new Set(['mineArchived']), totalCount: 0 });
fail(new Error('forbidden'));
await staleUnarchive;
expect(useMatchStore.getState().archivedMatches.map(item => item.id)).toEqual(['mineArchived']);
expect(useMatchStore.getState().totalCount).toBe(0);
});
it('does not carry a nudge cooldown across an account switch', async () => {
let release: (data: unknown) => void = () => undefined;
(appDataApi.sendNudge as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const staleSend = useMatchStore.getState().sendNudge();
useMatchStore.getState().clearMatches();
release({ success: true, notification_sent: true, next_nudge_available_at: '2026-08-28T00:00:00Z' });
await expect(staleSend).resolves.toEqual({ success: false, notificationSent: false });
expect(useMatchStore.getState().nudgeCooldownUntil).toBeNull();
// The 429 branch writes a cooldown too, and must be gated the same way.
let fail: (error: Error) => void = () => undefined;
(appDataApi.sendNudge as jest.Mock).mockReturnValueOnce(new Promise((_resolve, reject) => { fail = reject; }));
const staleLimit = useMatchStore.getState().sendNudge();
useMatchStore.getState().clearMatches();
fail(new ApiError('rate limited', 429, { cooldown_remaining_seconds: 60 }));
await staleLimit;
expect(useMatchStore.getState().nudgeCooldownUntil).toBeNull();
});
it('does not let a stale cooldown read clear the next account\'s cooldown', async () => {
let release: (profile: unknown) => void = () => undefined;
(appDataApi.nudgeStatus as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const stale = useMatchStore.getState().checkNudgeCooldown();
useMatchStore.getState().clearMatches();
const mine = new Date('2026-08-27T18:00:00Z');
useMatchStore.setState({ nudgeCooldownUntil: mine });
// The previous account not being in cooldown says nothing about this one.
release({ last_nudge_sent_at: null });
await stale;
expect(useMatchStore.getState().nudgeCooldownUntil).toEqual(mine);
});
it('does not let a stale fetchPendingQuestions response populate the store after sign-out, and lets the next account start immediately', async () => {
let release: (page: unknown) => void = () => undefined;
(apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const staleFetch = useMatchStore.getState().fetchPendingQuestions();
useMatchStore.getState().clearMatches();
release({ questions: [pending('stale')] });
await staleFetch;
expect(useMatchStore.getState().pendingQuestions).toEqual([]);
(apiClient.get as jest.Mock).mockResolvedValueOnce({ questions: [pending('fresh')] });
await useMatchStore.getState().fetchPendingQuestions();
expect(useMatchStore.getState().pendingQuestions.map(item => item.id)).toEqual(['fresh']);
});
});
it('clearMatches resets all user-scoped state', () => {
useMatchStore.setState({ matches: [match('m1')], archivedMatches: [match('m2')], pendingQuestions: [pending('p')], theirTurnQuestions: [pending('t')], currentView: 'archived', showArchived: true, isNudging: true });
useMatchStore.getState().clearMatches();
@@ -47,15 +47,25 @@ describe('messageStore API state', () => {
expect(useMessageStore.getState()).toMatchObject({ unreadCount: 1, lastMessage: { id: 'new' } });
});
it('marks partner messages read then reconciles global and per-match counts', async () => {
it('marks partner messages read and subtracts the cleared count without re-reading the total', async () => {
useMessageStore.setState({ unreadCount: 3 });
(chatApi.markRead as jest.Mock).mockResolvedValue({ updated: 2, read_at: '2026-08-27T00:00:00.000Z' });
(chatApi.unread as jest.Mock).mockResolvedValue({ total: 1, by_match: { m2: 1 } });
await useMessageStore.getState().markMatchMessagesAsRead('m1');
expect(chatApi.markRead).toHaveBeenCalledWith('m1');
expect(chatApi.unread).not.toHaveBeenCalled();
expect(useMessageStore.getState().unreadCount).toBe(1);
expect(useMatchStore.getState().matches[0]?.unreadCount).toBe(1);
});
it('spends no request and touches no count when the read API cleared nothing', async () => {
useMessageStore.setState({ unreadCount: 3 });
(chatApi.markRead as jest.Mock).mockResolvedValue({ updated: 0, read_at: null });
await useMessageStore.getState().markMatchMessagesAsRead('m1');
expect(chatApi.unread).not.toHaveBeenCalled();
expect(useMessageStore.getState().unreadCount).toBe(3);
expect(useMatchStore.getState().matches[0]?.unreadCount).toBe(3);
});
it('does not mark read without an authenticated user', async () => {
useAuthStore.setState({ user: null } as any);
await useMessageStore.getState().markMatchMessagesAsRead('m1');
@@ -69,6 +79,42 @@ describe('messageStore API state', () => {
expect(useMatchStore.getState().matches[0]?.unreadCount).toBe(3);
});
describe('account isolation (generation tokens)', () => {
it('does not let a stale unread total become the next account\'s badge', async () => {
let release: (value: unknown) => void = () => undefined;
(chatApi.unread as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const staleFetch = useMessageStore.getState().fetchUnreadCount();
// Sign-out or leaving a couple, while the request is still open.
useMessageStore.getState().clearMessages();
useMessageStore.setState({ unreadCount: 2 });
release({ total: 9, by_match: { m1: 9 } });
await staleFetch;
expect(useMessageStore.getState().unreadCount).toBe(2);
});
it('does not subtract a stale read receipt from the next account\'s badge', async () => {
useMessageStore.setState({ unreadCount: 3 });
let release: (value: unknown) => void = () => undefined;
(chatApi.markRead as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const staleRead = useMessageStore.getState().markMatchMessagesAsRead('m1');
useMessageStore.getState().clearMessages();
useMessageStore.setState({ unreadCount: 2 });
useMatchStore.setState({ matches: [{ id: 'm1', unreadCount: 2 } as any] });
release({ updated: 3, read_at: '2026-08-27T00:00:00.000Z' });
await staleRead;
// The badge is wrong whatever the match id; reusing 'm1' also makes the
// second half of the damage — the per-match count — visible.
expect(useMessageStore.getState().unreadCount).toBe(2);
expect(useMatchStore.getState().matches[0]?.unreadCount).toBe(2);
});
});
it('clears transient message state', () => {
useMessageStore.setState({ unreadCount: 3, lastMessage: { id: 'last' } as any, activeMatchId: 'm1' });
useMessageStore.getState().clearLastMessage();
@@ -64,4 +64,69 @@ describe('packsStore standalone API integration', () => {
expect(getSpy).not.toHaveBeenCalled();
expect(usePacksStore.getState().enabledPackIds).toEqual([]);
});
describe('account isolation (generation tokens)', () => {
it('does not let a stale fetchEnabledPacks response populate the store after clearPacks, and lets the next account start immediately', async () => {
let release: (page: unknown) => void = () => undefined;
const getSpy = jest.spyOn(apiClient, 'get').mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const staleFetch = usePacksStore.getState().fetchEnabledPacks();
// Simulates sign-out/account switch while the request is still in flight.
usePacksStore.getState().clearPacks();
release({ enabledPackIds: ['stale-pack'] });
await staleFetch;
expect(usePacksStore.getState().enabledPackIds).toEqual([]);
getSpy.mockResolvedValueOnce({ enabledPackIds: ['fresh-pack'] });
await usePacksStore.getState().fetchEnabledPacks();
expect(usePacksStore.getState().enabledPackIds).toEqual(['fresh-pack']);
});
it('does not let a stale fetchPackProgress response populate the store after clearPacks, and lets the next account start immediately', async () => {
let release: (page: unknown) => void = () => undefined;
const getSpy = jest.spyOn(apiClient, 'get').mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const staleFetch = usePacksStore.getState().fetchPackProgress();
usePacksStore.getState().clearPacks();
release({ progress: [{ packId: 'stale', totalQuestions: 1, answeredQuestions: 1 }] });
await staleFetch;
expect(usePacksStore.getState().packProgress.size).toBe(0);
getSpy.mockResolvedValueOnce({ progress: [{ packId: 'fresh', totalQuestions: 2, answeredQuestions: 1 }] });
await usePacksStore.getState().fetchPackProgress();
expect(usePacksStore.getState().packProgress.get('fresh')).toEqual({ totalQuestions: 2, answeredQuestions: 1 });
});
it('does not let a stale toggle write the previous couple\'s pack selection', async () => {
usePacksStore.setState({ enabledPackIds: [] } as any);
let release: (value: unknown) => void = () => undefined;
jest.spyOn(apiClient, 'put').mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const staleToggle = usePacksStore.getState().togglePack('pack1');
usePacksStore.getState().clearPacks();
usePacksStore.setState({ enabledPackIds: ['next-couple-pack'] } as any);
release({ enabledPackIds: ['pack1'] });
await expect(staleToggle).resolves.toEqual({ success: false, reason: 'stale' });
expect(usePacksStore.getState().enabledPackIds).toEqual(['next-couple-pack']);
});
it('does not roll a failed toggle back into the next couple\'s selection', async () => {
usePacksStore.setState({ enabledPackIds: ['pack1'] } as any);
let reject: (reason: unknown) => void = () => undefined;
jest.spyOn(apiClient, 'put').mockReturnValueOnce(new Promise((_resolve, fail) => { reject = fail; }));
jest.spyOn(console, 'error').mockImplementation(() => undefined);
const staleToggle = usePacksStore.getState().togglePack('pack1');
usePacksStore.getState().clearPacks();
usePacksStore.setState({ enabledPackIds: ['next-couple-pack'] } as any);
reject(new Error('offline'));
await expect(staleToggle).resolves.toEqual({ success: false, reason: 'stale' });
// The rollback would have re-added pack1, which this couple never enabled.
expect(usePacksStore.getState().enabledPackIds).toEqual(['next-couple-pack']);
});
});
});
@@ -265,6 +265,53 @@ describe('responsesStore', () => {
});
});
describe('account isolation (generation tokens)', () => {
it('does not let a stale fetchResponses response populate the store after clearResponses, and lets the next account start immediately', async () => {
let resolve!: (value: unknown) => void;
apiGet.mockReturnValueOnce(new Promise((done) => { resolve = done; }));
const staleFetch = useResponsesStore.getState().fetchResponses(true);
// Simulates sign-out/account switch while the request is still in flight.
useResponsesStore.getState().clearResponses();
resolve({ responses: [response({ id: 'stale' })], totalCount: 1 });
await staleFetch;
expect(useResponsesStore.getState().responses).toEqual([]);
apiGet.mockResolvedValueOnce({ responses: [response({ id: 'fresh' })], totalCount: 1 });
await useResponsesStore.getState().fetchResponses(true);
expect(useResponsesStore.getState().responses.map((r) => r.id)).toEqual(['fresh']);
});
it('does not let a stale edit rewrite the next account\'s row or refresh its matches', async () => {
// Question ids come from the shared catalogue, so the same id names a real
// row in both accounts' lists: a stale edit would land on one of them.
const fetchMatches = jest.fn();
useMatchStore.setState({ fetchMatches } as any);
useResponsesStore.setState({
responses: [response({ id: 'alice', answer: 'maybe', has_match: false, match_id: undefined })],
});
let resolve!: (value: unknown) => void;
apiPatch.mockReturnValueOnce(new Promise((done) => { resolve = done; }));
const staleEdit = useResponsesStore.getState().updateResponse('q1', 'yes');
useResponsesStore.getState().clearResponses();
const next = [response({ id: 'bob', answer: 'no', has_match: false, match_id: undefined })];
useResponsesStore.setState({ responses: next });
resolve({ success: true, new_match: { id: 'm2' } });
// The caller still hears what the server did for the account that asked.
await expect(staleEdit).resolves.toMatchObject({ success: true });
expect(useResponsesStore.getState().responses).toBe(next);
expect(useResponsesStore.getState().responses[0]).toMatchObject({
id: 'bob', answer: 'no', has_match: false,
});
expect(fetchMatches).not.toHaveBeenCalled();
});
});
describe('state controls', () => {
it('sets and toggles grouping options', () => {
useResponsesStore.getState().setGroupBy('pack');
@@ -0,0 +1,58 @@
import { apiClient } from '@/lib/apiClient';
import { useAuthStore } from '@/store/authStore';
import { type CoupleStreak, useStreakStore } from '@/store/streakStore';
jest.mock('@/lib/apiClient', () => ({ apiClient: { get: jest.fn() } }));
const apiGet = apiClient.get as jest.Mock;
function streak(overrides: Partial<CoupleStreak> = {}): CoupleStreak {
return {
couple_id: 'couple1', current_streak: 1, longest_streak: 1,
last_active_date: '2026-08-27', last_completed_date: '2026-08-27',
you_answered_today: true, partner_answered_today: false,
partner_name: 'Partner', timezone: 'UTC', streak_celebrated_at: 0,
created_at: '2026-08-01T00:00:00.000Z', updated_at: '2026-08-27T00:00:00.000Z',
...overrides,
};
}
describe('streakStore account isolation (generation tokens)', () => {
beforeEach(() => {
jest.clearAllMocks();
useStreakStore.setState({ streak: null, isLoading: false, error: null, loadedAt: null, generation: 0 });
useAuthStore.setState({ user: { id: 'me', couple_id: 'couple1' } } as any);
});
it('does not let a stale fetchStreak response populate the store after clearStreak, and lets the next account start immediately', async () => {
let resolve!: (value: unknown) => void;
apiGet.mockReturnValueOnce(new Promise((done) => { resolve = done; }));
const staleFetch = useStreakStore.getState().fetchStreak();
// Simulates sign-out/account switch while the request is still in flight.
useStreakStore.getState().clearStreak();
resolve({ streak: streak({ couple_id: 'stale-couple' }) });
await staleFetch;
expect(useStreakStore.getState().streak).toBeNull();
expect(useStreakStore.getState().loadedAt).toBeNull();
apiGet.mockResolvedValueOnce({ streak: streak({ couple_id: 'fresh-couple' }) });
await useStreakStore.getState().fetchStreak();
expect(useStreakStore.getState().streak?.couple_id).toBe('fresh-couple');
expect(useStreakStore.getState().loadedAt).not.toBeNull();
});
it('refreshStreak silently reloads only an already-loaded streak', async () => {
await useStreakStore.getState().refreshStreak();
expect(apiGet).not.toHaveBeenCalled();
apiGet.mockResolvedValueOnce({ streak: streak() });
await useStreakStore.getState().fetchStreak();
expect(useStreakStore.getState().loadedAt).not.toBeNull();
apiGet.mockResolvedValueOnce({ streak: streak({ current_streak: 2 }) });
await useStreakStore.getState().refreshStreak();
expect(useStreakStore.getState().streak?.current_streak).toBe(2);
});
});
+227 -227
View File
@@ -1,227 +1,227 @@
import React, { useEffect } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import Animated, { FadeInDown, useSharedValue, useAnimatedStyle, withRepeat, withSequence, withTiming, Easing } from 'react-native-reanimated';
import { useStreakStore } from '../store';
import type { CoupleStreak } from '../store';
import { colors, spacing, radius, typography, shadows } from '../theme';
interface StreakDisplayProps {
/** Whether to show the "longest streak" indicator */
showLongest?: boolean;
/** Animation delay for entry animation */
delay?: number;
/** Compact mode for smaller displays */
compact?: boolean;
}
/**
* The count is deliberately framed as a couple's, not a personal score: a shared streak
* is the thing worth keeping. The status line is the working half of that framing - it
* says who the day is waiting on - and the copy stays an invitation, because guilt
* between partners is the failure mode for this mechanic rather than the goal.
*/
export function streakStatus(streak: CoupleStreak): { headline: string; status: string } {
const named = streak.partner_name?.trim() || null;
// The same fallback reads wrong in both positions, so keep a sentence-start form.
const subject = named ?? 'Your partner';
const object = named ?? 'your partner';
const days = streak.current_streak;
const both = streak.you_answered_today && streak.partner_answered_today;
const headline = days > 0
? `You and ${object} — ${days} day${days === 1 ? '' : 's'} in a row`
: `You and ${object}`;
if (both) return { headline, status: days > 0 ? "You've both answered today" : 'Answered together today' };
if (streak.partner_answered_today) return { headline, status: `${subject} has answered today — your turn` };
if (streak.you_answered_today) return { headline, status: `Answered. Waiting on ${object}` };
return { headline, status: days > 0 ? 'One question each keeps it going' : 'Answer together to start a streak' };
}
/**
* Displays the couple's shared streak. The flame pulses while the streak is alive.
*/
export function StreakDisplay({ showLongest = false, delay = 0, compact = false }: StreakDisplayProps) {
const { streak, fetchStreak } = useStreakStore();
// Fetch streak on mount
useEffect(() => {
fetchStreak();
}, [fetchStreak]);
// Flame pulse animation
const scale = useSharedValue(1);
const hasStreak = (streak?.current_streak ?? 0) > 0;
useEffect(() => {
if (hasStreak) {
scale.value = withRepeat(
withSequence(
withTiming(1.1, { duration: 800, easing: Easing.inOut(Easing.ease) }),
withTiming(1, { duration: 800, easing: Easing.inOut(Easing.ease) })
),
-1,
true
);
} else {
scale.value = 1;
}
}, [hasStreak, scale]);
const flameStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
if (!streak) {
return null;
}
// A dormant couple gets no zero-state card. Showing "0 days" to a pair who simply
// have not played yet reads as a scolding for a streak they never started.
if (!hasStreak && !streak.you_answered_today && !streak.partner_answered_today) {
return null;
}
const { headline, status } = streakStatus(streak);
const currentStreak = streak.current_streak;
const longestStreak = streak.longest_streak;
// The partner having moved first is the one state the couple can still act on today.
const awaitingYou = streak.partner_answered_today && !streak.you_answered_today;
if (compact) {
return (
<Animated.View entering={FadeInDown.delay(delay).duration(400)}>
<View style={[styles.compactContainer, awaitingYou && styles.compactAwaiting]}>
<Animated.View style={flameStyle}>
<Ionicons
name="flame"
size={16}
color={hasStreak ? colors.warning : colors.textTertiary}
/>
</Animated.View>
<Text style={[styles.compactCount, !hasStreak && styles.inactiveText]}>
{currentStreak}
</Text>
</View>
</Animated.View>
);
}
return (
<Animated.View entering={FadeInDown.delay(delay).duration(500)}>
<View style={styles.container}>
<View style={[styles.card, shadows.sm, awaitingYou && styles.cardAwaiting]}>
<View style={styles.content}>
<Animated.View style={[styles.iconContainer, flameStyle]}>
<Ionicons
name="flame"
size={28}
color={hasStreak ? colors.warning : colors.textTertiary}
/>
</Animated.View>
<View style={styles.info}>
<Text style={styles.headline} numberOfLines={2}>
{headline}
</Text>
<Text style={[styles.status, awaitingYou && styles.statusAwaiting]} numberOfLines={2}>
{status}
</Text>
</View>
</View>
{showLongest && longestStreak > 0 && (
<View style={styles.longest}>
<Ionicons name="trophy-outline" size={12} color={colors.textTertiary} />
<Text style={styles.longestText}>
Best together: {longestStreak} day{longestStreak !== 1 ? 's' : ''}
</Text>
</View>
)}
</View>
</View>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
marginVertical: spacing.sm,
},
card: {
backgroundColor: colors.surface,
borderRadius: radius.lg,
padding: spacing.md,
borderWidth: 1,
borderColor: colors.border,
},
cardAwaiting: {
borderColor: colors.warning,
},
content: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
},
iconContainer: {
width: 44,
height: 44,
borderRadius: radius.full,
backgroundColor: colors.warningLight,
justifyContent: 'center',
alignItems: 'center',
},
info: {
flex: 1,
},
headline: {
...typography.headline,
color: colors.text,
},
status: {
...typography.subhead,
color: colors.textSecondary,
marginTop: 2,
},
statusAwaiting: {
color: colors.warning,
},
inactiveText: {
color: colors.textTertiary,
},
longest: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
marginTop: spacing.sm,
paddingTop: spacing.sm,
borderTopWidth: 1,
borderTopColor: colors.border,
},
longestText: {
...typography.caption1,
color: colors.textTertiary,
},
// Compact styles
compactContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
backgroundColor: colors.warningLight,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: radius.full,
},
compactAwaiting: {
borderWidth: 1,
borderColor: colors.warning,
},
compactCount: {
...typography.caption1,
fontWeight: '600',
color: colors.warning,
},
});
export default StreakDisplay;
import React, { useEffect } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import Animated, { FadeInDown, useSharedValue, useAnimatedStyle, withRepeat, withSequence, withTiming, Easing } from 'react-native-reanimated';
import { useStreakStore } from '../store';
import type { CoupleStreak } from '../store';
import { colors, spacing, radius, typography, shadows } from '../theme';
interface StreakDisplayProps {
/** Whether to show the "longest streak" indicator */
showLongest?: boolean;
/** Animation delay for entry animation */
delay?: number;
/** Compact mode for smaller displays */
compact?: boolean;
}
/**
* The count is deliberately framed as a couple's, not a personal score: a shared streak
* is the thing worth keeping. The status line is the working half of that framing - it
* says who the day is waiting on - and the copy stays an invitation, because guilt
* between partners is the failure mode for this mechanic rather than the goal.
*/
export function streakStatus(streak: CoupleStreak): { headline: string; status: string } {
const named = streak.partner_name?.trim() || null;
// The same fallback reads wrong in both positions, so keep a sentence-start form.
const subject = named ?? 'Your partner';
const object = named ?? 'your partner';
const days = streak.current_streak;
const both = streak.you_answered_today && streak.partner_answered_today;
const headline = days > 0
? `You and ${object} — ${days} day${days === 1 ? '' : 's'} in a row`
: `You and ${object}`;
if (both) return { headline, status: days > 0 ? "You've both answered today" : 'Answered together today' };
if (streak.partner_answered_today) return { headline, status: `${subject} has answered today — your turn` };
if (streak.you_answered_today) return { headline, status: `Answered. Waiting on ${object}` };
return { headline, status: days > 0 ? 'One question each keeps it going' : 'Answer together to start a streak' };
}
/**
* Displays the couple's shared streak. The flame pulses while the streak is alive.
*/
export function StreakDisplay({ showLongest = false, delay = 0, compact = false }: StreakDisplayProps) {
const { streak, ensureStreakLoaded } = useStreakStore();
// Fetch streak on mount
useEffect(() => {
void ensureStreakLoaded();
}, [ensureStreakLoaded]);
// Flame pulse animation
const scale = useSharedValue(1);
const hasStreak = (streak?.current_streak ?? 0) > 0;
useEffect(() => {
if (hasStreak) {
scale.value = withRepeat(
withSequence(
withTiming(1.1, { duration: 800, easing: Easing.inOut(Easing.ease) }),
withTiming(1, { duration: 800, easing: Easing.inOut(Easing.ease) })
),
-1,
true
);
} else {
scale.value = 1;
}
}, [hasStreak, scale]);
const flameStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
if (!streak) {
return null;
}
// A dormant couple gets no zero-state card. Showing "0 days" to a pair who simply
// have not played yet reads as a scolding for a streak they never started.
if (!hasStreak && !streak.you_answered_today && !streak.partner_answered_today) {
return null;
}
const { headline, status } = streakStatus(streak);
const currentStreak = streak.current_streak;
const longestStreak = streak.longest_streak;
// The partner having moved first is the one state the couple can still act on today.
const awaitingYou = streak.partner_answered_today && !streak.you_answered_today;
if (compact) {
return (
<Animated.View entering={FadeInDown.delay(delay).duration(400)}>
<View style={[styles.compactContainer, awaitingYou && styles.compactAwaiting]}>
<Animated.View style={flameStyle}>
<Ionicons
name="flame"
size={16}
color={hasStreak ? colors.warning : colors.textTertiary}
/>
</Animated.View>
<Text style={[styles.compactCount, !hasStreak && styles.inactiveText]}>
{currentStreak}
</Text>
</View>
</Animated.View>
);
}
return (
<Animated.View entering={FadeInDown.delay(delay).duration(500)}>
<View style={styles.container}>
<View style={[styles.card, shadows.sm, awaitingYou && styles.cardAwaiting]}>
<View style={styles.content}>
<Animated.View style={[styles.iconContainer, flameStyle]}>
<Ionicons
name="flame"
size={28}
color={hasStreak ? colors.warning : colors.textTertiary}
/>
</Animated.View>
<View style={styles.info}>
<Text style={styles.headline} numberOfLines={2}>
{headline}
</Text>
<Text style={[styles.status, awaitingYou && styles.statusAwaiting]} numberOfLines={2}>
{status}
</Text>
</View>
</View>
{showLongest && longestStreak > 0 && (
<View style={styles.longest}>
<Ionicons name="trophy-outline" size={12} color={colors.textTertiary} />
<Text style={styles.longestText}>
Best together: {longestStreak} day{longestStreak !== 1 ? 's' : ''}
</Text>
</View>
)}
</View>
</View>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
marginVertical: spacing.sm,
},
card: {
backgroundColor: colors.surface,
borderRadius: radius.lg,
padding: spacing.md,
borderWidth: 1,
borderColor: colors.border,
},
cardAwaiting: {
borderColor: colors.warning,
},
content: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
},
iconContainer: {
width: 44,
height: 44,
borderRadius: radius.full,
backgroundColor: colors.warningLight,
justifyContent: 'center',
alignItems: 'center',
},
info: {
flex: 1,
},
headline: {
...typography.headline,
color: colors.text,
},
status: {
...typography.subhead,
color: colors.textSecondary,
marginTop: 2,
},
statusAwaiting: {
color: colors.warning,
},
inactiveText: {
color: colors.textTertiary,
},
longest: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
marginTop: spacing.sm,
paddingTop: spacing.sm,
borderTopWidth: 1,
borderTopColor: colors.border,
},
longestText: {
...typography.caption1,
color: colors.textTertiary,
},
// Compact styles
compactContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
backgroundColor: colors.warningLight,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: radius.full,
},
compactAwaiting: {
borderWidth: 1,
borderColor: colors.warning,
},
compactCount: {
...typography.caption1,
fontWeight: '600',
color: colors.warning,
},
});
export default StreakDisplay;
+20 -13
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import { View, StyleSheet, KeyboardAvoidingView, Platform, Alert } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useFocusEffect, useIsFocused } from "@react-navigation/native";
@@ -60,20 +60,31 @@ export const ChatScreen: React.FC = () => {
const isFocused = useIsFocused();
// Typing indicator hook
// Each hook needs a value the other produces: the message poll reports typing
// state, and an arriving message clears the indicator. The ref breaks the cycle.
const clearTypingRef = useRef<() => void>(() => undefined);
const handleNewMessage = useCallback(() => clearTypingRef.current(), []);
// Message subscription hook
const { messages, setMessages, partnerTyping: syncedTyping } = useMessageSubscription({
matchId,
userId: user?.id,
onNewMessage: handleNewMessage,
isFocused,
});
// Typing indicator hook, reading the state the message poll already fetched
const { partnerTyping, sendTypingEvent, clearTypingIndicator } = useTypingIndicator({
channelName: `chat:${matchId}`,
matchId,
userId: user?.id,
isFocused,
externalTyping: syncedTyping,
});
// Message subscription hook
const { messages, setMessages, isFocusedRef } = useMessageSubscription({
matchId,
userId: user?.id,
onNewMessage: clearTypingIndicator,
});
useEffect(() => {
clearTypingRef.current = clearTypingIndicator;
}, [clearTypingIndicator]);
// Message actions hook for deletion and reporting
@@ -174,16 +185,12 @@ export const ChatScreen: React.FC = () => {
// Track active chat to prevent notifications for current chat
useFocusEffect(
useCallback(() => {
isFocusedRef.current = true;
if (matchId) {
setActiveMatchId(matchId);
markMatchMessagesAsRead(matchId);
Events.matchViewed();
}
return () => {
isFocusedRef.current = false;
setActiveMatchId(null);
};
return () => setActiveMatchId(null);
}, [matchId, setActiveMatchId, markMatchMessagesAsRead])
);
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useIsFocused } from "@react-navigation/native";
import Animated, { FadeIn } from "react-native-reanimated";
import { GradientBackground } from "../../components/ui";
import { Paywall } from "../../components/paywall";
@@ -20,7 +21,8 @@ const TABS: { key: Tab; label: string }[] = [
];
export function DaresScreen() {
const dares = useDares();
const isFocused = useIsFocused();
const dares = useDares({ isFocused });
const [tab, setTab] = useState<Tab>("active");
const [sheetPack, setSheetPack] = useState<DarePack | null>(null);
const [sheetOpen, setSheetOpen] = useState(false);
@@ -1,11 +1,21 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { daresApi } from "../../../lib/daresApi";
import { ApiError } from "../../../lib/apiClient";
import { usePolling } from "../../../hooks/usePolling";
import type { DareCatalog, DareStats, SendDarePayload, SentDare } from "../types";
/** Matches the chat poll cadence; the API has no realtime channel. */
const POLL_INTERVAL_MS = 5_000;
export interface UseDaresConfig {
/**
* Supplied by the screen rather than read from navigation here, so the hook
* stays renderable without a navigator. Polling stops when the screen is not
* on top; `usePolling` additionally stops it when the app backgrounds.
*/
isFocused?: boolean;
}
export interface UseDaresReturn {
active: SentDare[];
history: SentDare[];
@@ -25,7 +35,19 @@ export interface UseDaresReturn {
cancel: (dareId: string) => Promise<void>;
}
export function useDares(): UseDaresReturn {
/**
* Identity of the active set as the user experiences it. History, stats and the
* remaining-send allowance only move when a dare enters or leaves this set or
* changes status, so this is the signal that says the slower endpoints are worth
* re-reading.
*/
const activeSignature = (dares: SentDare[]): string =>
dares
.map(dare => `${dare.id}:${dare.status}`)
.sort()
.join("|");
export function useDares({ isFocused = true }: UseDaresConfig = {}): UseDaresReturn {
const [active, setActive] = useState<SentDare[]>([]);
const [history, setHistory] = useState<SentDare[]>([]);
const [catalog, setCatalog] = useState<DareCatalog | null>(null);
@@ -36,42 +58,63 @@ export function useDares(): UseDaresReturn {
const [paywallReason, setPaywallReason] = useState<string | null>(null);
const fetchingRef = useRef(false);
const mountedRef = useRef(true);
const signatureRef = useRef<string | null>(null);
const refresh = useCallback(async () => {
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
/**
* `full` reads everything; the polling path reads only the active list and
* follows up on the rest when that list actually changed. A failure updates
* the UI's error state either way, but is only rethrown for the poll path
* (`throwOnError`): `usePolling` needs the rejection to drive its backoff,
* while pull-to-refresh and post-mutation callers already surface the error
* themselves and must not turn it into an unhandled rejection.
*/
const load = useCallback(async (full: boolean, throwOnError = false) => {
if (fetchingRef.current) return;
fetchingRef.current = true;
try {
const [activeResult, historyResult, catalogResult, statsResult] = await Promise.all([
daresApi.list("active"),
daresApi.list("history"),
daresApi.catalog(),
daresApi.stats(),
]);
const activeResult = await daresApi.list("active");
if (!mountedRef.current) return;
setActive(activeResult.dares);
setHistory(historyResult.dares);
setCatalog(catalogResult);
setStats(statsResult);
const signature = activeSignature(activeResult.dares);
const changed = signature !== signatureRef.current;
signatureRef.current = signature;
if (full || changed) {
const [historyResult, catalogResult, statsResult] = await Promise.all([
daresApi.list("history"),
daresApi.catalog(),
daresApi.stats(),
]);
if (!mountedRef.current) return;
setHistory(historyResult.dares);
setCatalog(catalogResult);
setStats(statsResult);
}
setError(null);
} catch (cause) {
if (mountedRef.current) {
setError(cause instanceof ApiError ? cause.message : "Could not load dares");
}
if (throwOnError) throw cause;
} finally {
fetchingRef.current = false;
if (mountedRef.current) setLoading(false);
}
}, []);
useEffect(() => {
mountedRef.current = true;
void refresh();
const timer = setInterval(() => void refresh(), POLL_INTERVAL_MS);
return () => {
mountedRef.current = false;
clearInterval(timer);
};
}, [refresh]);
/** Pull-to-refresh and post-mutation reloads always read the slow endpoints too. */
const refresh = useCallback(() => load(true), [load]);
const poll = useCallback(() => load(false, true), [load]);
usePolling(poll, { intervalMs: POLL_INTERVAL_MS, enabled: isFocused });
/** Premium refusals steer to the paywall; everything else surfaces inline. */
const handleFailure = useCallback((cause: unknown) => {
@@ -8,6 +8,7 @@ import {
useWindowDimensions,
} from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useIsFocused } from '@react-navigation/native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import * as Haptics from 'expo-haptics';
@@ -35,6 +36,7 @@ export const LiveDrawScreen: React.FC = () => {
const insets = useSafeAreaInsets();
const { user } = useAuthStore();
const coupleId = coupleIdParam || user?.couple_id;
const isFocused = useIsFocused();
const { width: screenWidth } = useWindowDimensions();
const [selectedColor, setSelectedColor] = useState(PRESET_COLORS[0]);
@@ -95,6 +97,7 @@ export const LiveDrawScreen: React.FC = () => {
const sync = useDrawingSync({
coupleId: coupleId!,
userId: user?.id,
isFocused,
onStrokeStart: handleStrokeStartFromPartner,
onStrokeContinue: handleStrokeContinueFromPartner,
onStrokeEnd: handleStrokeEndFromPartner,
@@ -1,9 +1,13 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { ApiError } from '../../../lib/apiClient';
import { appDataApi, type LiveDrawState } from '../../../lib/appDataApi';
import { usePolling } from '../../../hooks/usePolling';
import type { StrokeSegment, StrokePoint } from '../types';
import { updateWidget } from '../utils/widgetBridge';
/** Fast enough for a shared canvas to feel live, and only while one is on screen. */
const POLL_INTERVAL_MS = 750;
interface UseDrawingSyncConfig {
coupleId: string;
userId: string | undefined;
@@ -14,6 +18,12 @@ interface UseDrawingSyncConfig {
onUndo: (strokeId: string) => void;
onRedo: (stroke: StrokeSegment) => void;
onLoadStrokes: (strokes: StrokeSegment[]) => void;
/**
* Supplied by the screen rather than read from navigation here, so the hook
* stays renderable without a navigator. A canvas nobody is looking at polls
* nothing; `usePolling` also stops it when the app goes to the background.
*/
isFocused?: boolean;
}
interface UseDrawingSyncReturn {
@@ -52,7 +62,7 @@ export function mergeDrawingChanges(base: StrokeSegment[], desired: StrokeSegmen
}
export function useDrawingSync(config: UseDrawingSyncConfig): UseDrawingSyncReturn {
const { coupleId, userId, onLoadStrokes } = config;
const { coupleId, userId, onLoadStrokes, isFocused = true } = config;
const revisionRef = useRef(0);
const baselineRef = useRef<StrokeSegment[]>([]);
const workingRef = useRef<StrokeSegment[]>([]);
@@ -132,19 +142,25 @@ export function useDrawingSync(config: UseDrawingSyncConfig): UseDrawingSyncRetu
void appDataApi.getLiveDraw().then(acceptState).catch(error => console.error('Failed to load live drawing:', error));
}, [coupleId, acceptState]);
useEffect(() => {
if (!coupleId || !userId) return;
let cancelled = false;
const poll = async () => {
if (pendingWritesRef.current > 0) return;
try {
const state = await appDataApi.getLiveDraw();
if (!cancelled && state.revision > revisionRef.current) acceptState(state);
} catch (error) { console.error('Failed to poll live drawing:', error); }
};
const timer = setInterval(() => void poll(), 750);
return () => { cancelled = true; clearInterval(timer); if (throttleRef.current) clearTimeout(throttleRef.current); };
}, [coupleId, userId, acceptState]);
// Our own writes already return the authoritative state, so reading over the top
// of one in flight would only race it back to a revision we are about to replace.
const poll = useCallback(async () => {
if (pendingWritesRef.current > 0) return;
const state = await appDataApi.getLiveDraw();
if (state.revision > revisionRef.current) acceptState(state);
}, [acceptState]);
// `leading: false` because the mount effect above already read the initial state;
// usePolling adds the overlap guard, backoff and background pause the raw
// interval had none of.
usePolling(poll, {
intervalMs: POLL_INTERVAL_MS,
enabled: Boolean(coupleId) && Boolean(userId) && isFocused,
leading: false,
resetKey: `${coupleId}:${userId ?? ''}`,
});
useEffect(() => () => { if (throttleRef.current) clearTimeout(throttleRef.current); }, []);
const persistStrokes = useCallback((strokes: StrokeSegment[]) => {
if (throttleRef.current) clearTimeout(throttleRef.current);
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useFocusEffect, useLocalSearchParams } from "expo-router";
import { useAuthStore, usePacksStore, useStreakStore } from "../../../store";
import { useAuthStore, usePacksStore, useResponsesStore, useStreakStore } from "../../../store";
import { skipQuestion, getSkippedQuestionIds } from "../../../lib/skippedQuestions";
import { ApiError, apiClient } from "../../../lib/apiClient";
import { Events } from "../../../lib/analytics";
@@ -352,6 +352,12 @@ export const useSwipeScreen = () => {
// wrong partner state for the rest of the session. Refreshing it must never
// interfere with recording the answer, so it stays off the critical path.
void useStreakStore.getState().fetchStreak().catch(() => undefined);
// This answer changed pack progress and the answer history. Marking their
// caches stale costs no request: whichever screen the user opens next
// reloads once, and the ones they do not open stay quiet.
usePacksStore.getState().invalidatePacks();
useResponsesStore.getState().invalidateResponses();
} catch (error) {
// A 429 means the server rejected this answer outright. Do not advance the
// index, or the question is silently skipped and lost for the day.
+3
View File
@@ -1,4 +1,7 @@
export { useAmbientOrbAnimation } from './useAmbientOrbAnimation';
export { usePolling } from './usePolling';
export type { UsePollingOptions } from './usePolling';
export { useCoupleSync, SYNC_INTERVAL_MS } from './useCoupleSync';
export type { AmbientOrbConfig } from './useAmbientOrbAnimation';
export { useFeatureInterest } from './useFeatureInterest';
export { useMediaPicker } from './useMediaPicker';
+116
View File
@@ -0,0 +1,116 @@
import { useCallback, useRef } from 'react';
import { appDataApi, type SyncSummary } from '../lib/appDataApi';
import { useAuthStore } from '../store/authStore';
import { useMatchStore } from '../store/matchStore';
import { useMessageStore } from '../store/messageStore';
import { usePacksStore } from '../store/packsStore';
import { useStreakStore } from '../store/streakStore';
import { usePolling } from './usePolling';
/**
* Slower than the five-second full refresh it replaces, because the request it
* makes is a change summary rather than five payloads: the app reacts to a
* partner's action within this window, but pays one small query for it.
*/
export const SYNC_INTERVAL_MS = 15_000;
/**
* Keeps cached domain data honest without re-fetching it. Each poll reads one
* summary of the couple's state and refreshes only the domains whose markers
* moved — and only the ones already on screen, so a view nobody has opened is
* not loaded in the background.
*/
export function useCoupleSync(userId: string | undefined, coupleId: string | null | undefined): void {
const previousRef = useRef<SyncSummary | null>(null);
const identityRef = useRef<string>('');
// `sync` is memoized on a stable callback, so `taskRef` inside usePolling
// never has to restart the schedule on every render; it reads the current
// identity through this ref instead of closing over the arguments from
// whichever render created it.
const identityValuesRef = useRef({ userId, coupleId });
identityValuesRef.current = { userId, coupleId };
const latestIdentity = useCallback(() => {
const { userId, coupleId } = identityValuesRef.current;
return `${userId ?? ''}:${coupleId ?? ''}`;
}, []);
const sync = useCallback(async () => {
const identity = latestIdentity();
if (identity !== identityRef.current) {
// A new account's first summary must not be diffed against the prior
// account's markers, or every domain would silently "refresh" on login.
identityRef.current = identity;
previousRef.current = null;
}
const summary = await appDataApi.syncSummary();
// The account can change while this request is in flight. The response
// describes the identity that asked for it, so once that is no longer the
// current one it may not touch anything: not the badge, not the baseline
// the next account will diff against, and not a single refresh decision.
// Both checks matter — `identityRef` catches a newer sync that has already
// started, and `latestIdentity()` catches a switch that has only rendered.
if (identityRef.current !== identity || latestIdentity() !== identity) return;
const previous = previousRef.current;
previousRef.current = summary;
// The badge count comes straight out of the summary, so keeping it current
// costs nothing beyond this request.
if (summary.unread_total !== previous?.unread_total) {
useMessageStore.getState().setUnreadCount(summary.unread_total);
}
// The first poll only establishes the baseline: every screen loads its own
// data on mount, and refreshing it again here would undo the saving.
if (!previous) return;
const auth = useAuthStore.getState();
const matchStore = useMatchStore.getState();
const refreshes: Promise<unknown>[] = [];
if (summary.profile_updated_at !== previous.profile_updated_at || summary.couple_id !== previous.couple_id) {
// fetchUser also refreshes the couple when one is set.
refreshes.push(auth.fetchUser());
} else if (summary.partner_id !== previous.partner_id || summary.partner_updated_at !== previous.partner_updated_at) {
refreshes.push(auth.fetchCouple());
}
if (matchStore.loadedAt.active !== undefined && (
summary.match_count !== previous.match_count ||
summary.new_match_count !== previous.new_match_count ||
summary.latest_match_at !== previous.latest_match_at ||
// Neither count moves when a partner edits an existing match's type or
// response summary through response editing.
summary.match_state_fingerprint !== previous.match_state_fingerprint ||
// `unread_total` can hold steady while unread redistributes between
// matches (one read, another receives a message), which reorders the
// unread-first list without moving the total.
summary.match_unread_fingerprint !== previous.match_unread_fingerprint)) {
refreshes.push(matchStore.fetchMatches(true, { silent: true }));
}
if (matchStore.loadedAt.pending !== undefined && summary.pending_yours !== previous.pending_yours) {
refreshes.push(matchStore.fetchPendingQuestions({ silent: true }));
}
if (matchStore.loadedAt.their_turn !== undefined && summary.pending_theirs !== previous.pending_theirs) {
refreshes.push(matchStore.fetchTheirTurnQuestions({ silent: true }));
}
// Only refresh a domain the user has actually opened, matching the rule
// that unopened domains stay unloaded.
if (usePacksStore.getState().enabledPacksLoadedAt !== null
&& summary.enabled_packs_fingerprint !== previous.enabled_packs_fingerprint) {
refreshes.push(usePacksStore.getState().fetchEnabledPacks());
}
if (summary.streak_updated_at !== previous.streak_updated_at) {
refreshes.push(useStreakStore.getState().refreshStreak());
}
await Promise.allSettled(refreshes);
}, [latestIdentity]);
usePolling(sync, {
intervalMs: SYNC_INTERVAL_MS,
enabled: Boolean(userId),
resetKey: `${userId ?? ''}:${coupleId ?? ''}`,
});
}
+97 -32
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { chatApi } from '../lib/chatApi';
import { chatApi, type MessagesPage } from '../lib/chatApi';
import { usePolling } from './usePolling';
import type { Message } from '../features/chat/types';
export interface UseMessageSubscriptionConfig {
@@ -7,51 +8,115 @@ export interface UseMessageSubscriptionConfig {
userId: string | undefined;
onNewMessage?: () => void;
pollInterval?: number;
/** Whether the chat is on screen. Polling and read receipts stop when it is not. */
isFocused?: boolean;
}
export interface UseMessageSubscriptionReturn {
messages: Message[];
setMessages: React.Dispatch<React.SetStateAction<Message[]>>;
loading: boolean;
isFocusedRef: React.MutableRefObject<boolean>;
/** Partner typing state folded into the message poll; undefined until the server reports it. */
partnerTyping: boolean | undefined;
/** Fetches the next delta now, e.g. straight after sending. */
refresh: () => Promise<void>;
}
/** Authenticated API polling replaces Supabase Realtime for self-hosted chat. */
export const useMessageSubscription = ({ matchId, userId, onNewMessage, pollInterval = 1_500 }: UseMessageSubscriptionConfig): UseMessageSubscriptionReturn => {
const timeOf = (message: Message): number => {
const parsed = message.created_at ? Date.parse(message.created_at) : Number.NaN;
return Number.isNaN(parsed) ? 0 : parsed;
};
/** Newest first, matching the order the API returns a snapshot in. */
const byNewest = (a: Message, b: Message): number => {
const delta = timeOf(b) - timeOf(a);
if (delta !== 0) return delta;
return a.id < b.id ? 1 : a.id > b.id ? -1 : 0;
};
/**
* Returns the previous array unchanged when a delta carries nothing, so an idle
* poll re-renders neither this hook's consumers nor the message list.
*/
export const mergeMessagePage = (previous: Message[], page: MessagesPage): Message[] => {
if (page.complete !== false) return page.messages;
const removed = page.removed_ids ?? [];
if (page.messages.length === 0 && removed.length === 0) return previous;
const byId = new Map(previous.map(message => [message.id, message]));
for (const message of page.messages) byId.set(message.id, message);
for (const id of removed) byId.delete(id);
return [...byId.values()].sort(byNewest);
};
/**
* Authenticated API polling replaces Supabase Realtime for self-hosted chat.
*
* Each poll asks only for what changed since the cursor the server issued last
* time, so an idle conversation costs an all-but-empty response instead of the
* five hundred messages the screen already has. Read receipts are written only
* when the response actually contains an unread message from the partner, which
* on an idle chat is never.
*/
export const useMessageSubscription = ({
matchId,
userId,
onNewMessage,
pollInterval = 2_000,
isFocused = true,
}: UseMessageSubscriptionConfig): UseMessageSubscriptionReturn => {
const [messages, setMessages] = useState<Message[]>([]);
const [loading, setLoading] = useState(true);
const isFocusedRef = useRef(false);
const [loading, setLoading] = useState(Boolean(matchId && userId));
const [partnerTyping, setPartnerTyping] = useState<boolean | undefined>(undefined);
const sinceRef = useRef<string | null>(null);
const knownIdsRef = useRef<Set<string>>(new Set());
const fetchingRef = useRef(false);
const loadingRef = useRef(true);
const notifyRef = useRef(onNewMessage);
notifyRef.current = onNewMessage;
// A different conversation shares none of this one's state, and the poll below
// restarts on the same key so the new one loads immediately.
useEffect(() => {
sinceRef.current = null;
knownIdsRef.current = new Set();
loadingRef.current = Boolean(matchId && userId);
setMessages([]);
setPartnerTyping(undefined);
setLoading(loadingRef.current);
}, [matchId, userId]);
const refresh = useCallback(async (initial = false) => {
if (!matchId || !userId || fetchingRef.current) return;
fetchingRef.current = true;
const refresh = useCallback(async () => {
if (!matchId || !userId) return;
const since = sinceRef.current;
try {
const response = await chatApi.listMessages(matchId);
const partnerArrived = !initial && response.messages.some(message => message.user_id !== userId && !knownIdsRef.current.has(message.id));
knownIdsRef.current = new Set(response.messages.map(message => message.id));
setMessages(response.messages);
if (isFocusedRef.current) await chatApi.markRead(matchId);
if (partnerArrived) onNewMessage?.();
const page = await chatApi.listMessages(matchId, { ...(since ? { since } : {}), typing: true });
// Without a cursor the server cannot answer with deltas, so keep asking
// for snapshots rather than silently freezing on a stale page.
sinceRef.current = page.server_time ?? null;
if (page.typing) setPartnerTyping(page.typing.typing);
const arrivals = page.messages.filter(message => message.user_id !== userId && !knownIdsRef.current.has(message.id));
for (const message of page.messages) knownIdsRef.current.add(message.id);
for (const id of page.removed_ids ?? []) knownIdsRef.current.delete(id);
setMessages(previous => mergeMessagePage(previous, page));
if (isFocused && page.messages.some(message => message.user_id !== userId && !message.read_at)) {
await chatApi.markRead(matchId);
}
if (!loadingRef.current && arrivals.length > 0) notifyRef.current?.();
} finally {
fetchingRef.current = false;
if (initial) setLoading(false);
if (loadingRef.current) {
loadingRef.current = false;
setLoading(false);
}
}
}, [matchId, userId, onNewMessage]);
}, [matchId, userId, isFocused]);
useEffect(() => {
if (!matchId || !userId) {
setMessages([]);
setLoading(false);
return;
}
setLoading(true);
knownIdsRef.current = new Set();
void refresh(true).catch(() => undefined);
const timer = setInterval(() => void refresh(false).catch(() => undefined), pollInterval);
return () => clearInterval(timer);
}, [matchId, userId, pollInterval, refresh]);
usePolling(refresh, {
intervalMs: pollInterval,
enabled: Boolean(matchId && userId && isFocused),
resetKey: `${matchId ?? ''}:${userId ?? ''}`,
});
return { messages, setMessages, loading, isFocusedRef };
return { messages, setMessages, loading, partnerTyping, refresh };
};
+92
View File
@@ -0,0 +1,92 @@
import { useEffect, useRef } from 'react';
import { isForeground, subscribeToForeground } from '../lib/appForeground';
export interface UsePollingOptions {
intervalMs: number;
/** Caller-owned relevance: screen focus, a loaded id, a paired couple. */
enabled?: boolean;
/** Run once as soon as polling becomes active. Default true. */
leading?: boolean;
/** Ceiling for the exponential backoff applied after consecutive failures. */
maxIntervalMs?: number;
/**
* Restarts the schedule when it changes, so a caller that switches subject —
* a different chat, a different couple — gets an immediate run instead of
* waiting out the interval left over from the previous one.
*/
resetKey?: string | number;
}
/**
* A recurring task with the three properties every poller in this app needs and
* none of them had: it stops while the app is backgrounded, it never runs twice
* concurrently, and it backs off instead of hammering an API that is failing.
*
* The next run is scheduled after the previous one settles rather than on a fixed
* `setInterval`, so a slow response delays the next request instead of queueing
* one behind it. `task` is read through a ref, so a caller may pass a fresh
* closure every render without restarting the schedule.
*/
export function usePolling(task: () => Promise<void>, options: UsePollingOptions): void {
const { intervalMs, enabled = true, leading = true, maxIntervalMs = intervalMs * 8, resetKey } = options;
const taskRef = useRef(task);
taskRef.current = task;
useEffect(() => {
if (!enabled) return;
let cancelled = false;
let running = false;
let failures = 0;
let foreground = isForeground();
let timer: ReturnType<typeof setTimeout> | null = null;
const clear = () => {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
};
const schedule = () => {
if (cancelled || !foreground || timer !== null) return;
const delay = Math.min(intervalMs * 2 ** failures, maxIntervalMs);
timer = setTimeout(() => {
timer = null;
void run();
}, delay);
};
const run = async () => {
if (cancelled || running || !foreground) return;
running = true;
try {
await taskRef.current();
failures = 0;
} catch {
failures += 1;
} finally {
running = false;
schedule();
}
};
const unsubscribe = subscribeToForeground((next) => {
if (next === foreground) return;
foreground = next;
if (!foreground) {
clear();
return;
}
// Back from the background: the cached data is as stale as the absence
// was long, so catch up now rather than after another full interval.
failures = 0;
void run();
});
if (leading) void run();
else schedule();
return () => {
cancelled = true;
clear();
unsubscribe();
};
}, [enabled, intervalMs, leading, maxIntervalMs, resetKey]);
}
+38 -19
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { chatApi } from '../lib/chatApi';
import { usePolling } from './usePolling';
export interface UseTypingIndicatorConfig {
channelName: string;
@@ -9,6 +10,12 @@ export interface UseTypingIndicatorConfig {
isFocused?: boolean;
throttleInterval?: number;
pollInterval?: number;
/**
* Typing state already known from another request. When supplied this hook
* stops polling for it, which is how the chat screen gets typing state for
* free out of the message poll instead of a second request every interval.
*/
externalTyping?: boolean | undefined;
}
export interface UseTypingIndicatorReturn {
@@ -17,31 +24,43 @@ export interface UseTypingIndicatorReturn {
clearTypingIndicator: () => void;
}
export const useTypingIndicator = ({ channelName, userId, matchId = channelName.replace(/^chat:/, ''), isFocused = true, throttleInterval = 2_000, pollInterval = 1_500 }: UseTypingIndicatorConfig): UseTypingIndicatorReturn => {
export const useTypingIndicator = ({
channelName,
userId,
matchId = channelName.replace(/^chat:/, ''),
isFocused = true,
throttleInterval = 2_000,
pollInterval = 1_500,
externalTyping,
}: UseTypingIndicatorConfig): UseTypingIndicatorReturn => {
const [partnerTyping, setPartnerTyping] = useState(false);
const lastTypingEventRef = useRef(0);
const external = externalTyping !== undefined;
useEffect(() => {
if (!matchId || !userId || !isFocused) {
if (externalTyping !== undefined) setPartnerTyping(externalTyping);
}, [externalTyping]);
// Losing the subject stops both sources of truth, so drop the stale indicator
// rather than leaving the partner typing forever in a chat nobody is watching.
useEffect(() => {
if (!matchId || !userId || !isFocused) setPartnerTyping(false);
}, [matchId, userId, isFocused]);
const poll = useCallback(async () => {
try {
const state = await chatApi.getTyping(matchId);
setPartnerTyping(state.typing);
} catch {
setPartnerTyping(false);
return;
}
let active = true;
const poll = async () => {
try {
const state = await chatApi.getTyping(matchId);
if (active) setPartnerTyping(state.typing);
} catch {
if (active) setPartnerTyping(false);
}
};
void poll();
const timer = setInterval(() => void poll(), pollInterval);
return () => {
active = false;
clearInterval(timer);
};
}, [matchId, userId, isFocused, pollInterval]);
}, [matchId]);
usePolling(poll, {
intervalMs: pollInterval,
enabled: !external && Boolean(matchId) && Boolean(userId) && isFocused,
resetKey: matchId,
});
const sendTypingEvent = useCallback(() => {
if (!matchId || !userId) return;
+29
View File
@@ -10,7 +10,36 @@ export interface LiveDrawState {
updated_by: string | null;
}
/**
* A change summary, not a payload: markers the client compares against the ones
* it saw last time to decide which domain, if any, is worth refetching. It
* replaces a periodic full refresh of profile, couple, matches, pending
* questions, packs and unread counts with one small request.
*/
export interface SyncSummary {
server_time: string;
couple_id: string | null;
profile_updated_at: string | null;
partner_id: string | null;
partner_updated_at: string | null;
match_count: number;
new_match_count: number;
latest_match_at: string | null;
pending_yours: number;
pending_theirs: number;
unread_total: number;
/** couple_packs carries no timestamp, so equal-and-opposite toggles are caught by a digest. */
enabled_packs_fingerprint: string | null;
/** Digest of each visible match's type and response summary; moves on an in-place response edit that the count/latest-at markers miss. */
match_state_fingerprint: string;
/** Digest of each visible match's unread count; moves when unread redistributes between matches without changing the total. */
match_unread_fingerprint: string;
/** couple_streaks.updated_at, so a partner's answer can silently refresh an already-loaded streak. */
streak_updated_at: string | null;
}
export const appDataApi = {
syncSummary: () => apiClient.get<SyncSummary>('/v1/me/sync'),
packContext: (packId: string) => apiClient.get<{ pack: { id: string; name: string; icon: string | null } }>(`/v1/packs/${encodeURIComponent(packId)}/context`),
packQuestions: (packId: string) => apiClient.get<{ questions: Question[] }>(`/v1/packs/${encodeURIComponent(packId)}/questions`),
packTeaser: (packId: string) => apiClient.get<{ questions: Array<{ id: string; text: string; intensity: number }> }>(`/v1/packs/${encodeURIComponent(packId)}/teaser`),
+21
View File
@@ -0,0 +1,21 @@
import { AppState, type AppStateStatus } from 'react-native';
/**
* `background` is a real suspension and `inactive` is the moment behind the app
* switcher, a call, or a system prompt. Anything else — including states a test
* harness never sets — counts as foreground, so recurring work fails towards
* running rather than towards being silently disabled forever.
*/
export function isForegroundState(state: AppStateStatus | string | undefined): boolean {
return state !== 'background' && state !== 'inactive';
}
export function isForeground(): boolean {
return isForegroundState(AppState.currentState as AppStateStatus | undefined);
}
/** Reports the new foreground flag on every transition. Returns an unsubscribe. */
export function subscribeToForeground(onChange: (foreground: boolean) => void): () => void {
const subscription = AppState.addEventListener('change', (state) => onChange(isForegroundState(state)));
return () => subscription.remove();
}
+34 -2
View File
@@ -1,8 +1,40 @@
import { apiClient } from './apiClient';
import type { Message, ReportReason } from '../features/chat/types';
export interface PartnerTyping {
typing: boolean;
expires_at: string | null;
}
/**
* A snapshot when `complete`, otherwise only what changed since the cursor the
* previous page handed back. Delta rows are whole messages, not patches, so a
* caller merges them by id without a second shape to understand.
*/
export interface MessagesPage {
messages: Message[];
/** Messages the caller deleted for themselves since the cursor. */
removed_ids?: string[];
/** Cursor to send as `since` on the next poll. */
server_time?: string;
complete?: boolean;
/** Present when requested, saving a separate typing request. */
typing?: PartnerTyping;
}
export interface ListMessagesOptions {
since?: string;
typing?: boolean;
}
export const chatApi = {
listMessages: (matchId: string) => apiClient.get<{ messages: Message[] }>(`/v1/matches/${encodeURIComponent(matchId)}/messages`),
listMessages: (matchId: string, options: ListMessagesOptions = {}) => {
const query: string[] = [];
if (options.since) query.push(`since=${encodeURIComponent(options.since)}`);
if (options.typing) query.push('typing=true');
const suffix = query.length > 0 ? `?${query.join('&')}` : '';
return apiClient.get<MessagesPage>(`/v1/matches/${encodeURIComponent(matchId)}/messages${suffix}`);
},
sendText: (matchId: string, content: string) => apiClient.post<{ message: Message }>(`/v1/matches/${encodeURIComponent(matchId)}/messages`, { content }),
unread: () => apiClient.get<{ total: number; by_match: Record<string, number> }>('/v1/chat/unread'),
markRead: (matchId: string) => apiClient.post<{ updated: number; read_at: string }>(`/v1/matches/${encodeURIComponent(matchId)}/read`),
@@ -11,5 +43,5 @@ export const chatApi = {
deleteForEveryone: (messageId: string) => apiClient.delete<{ deleted: true; message: Message }>(`/v1/messages/${encodeURIComponent(messageId)}?scope=everyone`),
report: (messageId: string, reason: ReportReason) => apiClient.post<{ reported: true }>(`/v1/messages/${encodeURIComponent(messageId)}/reports`, { reason }),
setTyping: (matchId: string) => apiClient.put<{ typing: true }>(`/v1/matches/${encodeURIComponent(matchId)}/typing`),
getTyping: (matchId: string) => apiClient.get<{ typing: boolean; expires_at: string | null }>(`/v1/matches/${encodeURIComponent(matchId)}/typing`),
getTyping: (matchId: string) => apiClient.get<PartnerTyping>(`/v1/matches/${encodeURIComponent(matchId)}/typing`),
};
+175 -171
View File
@@ -4,104 +4,106 @@ import { authClient } from "../lib/authClient";
import { coupleApi } from "../lib/coupleApi";
import { profileSettingsApi } from "../lib/profileSettingsApi";
import { syncTimezone } from "../lib/reportedTimezone";
import { Events } from "../lib/analytics";
import type { Profile, Couple } from "@/types";
interface AuthState {
user: Profile | null;
couple: Couple | null;
partner: Profile | null;
isLoading: boolean;
isAuthenticated: boolean;
isAnonymous: boolean;
// Actions
fetchUser: () => Promise<void>;
fetchCouple: () => Promise<void>;
refreshPartner: () => Promise<Profile | null>;
signOut: () => Promise<void>;
setUser: (user: Profile | null) => void;
updateLastActive: () => Promise<void>;
}
// Import other stores lazily to avoid circular dependency issues
const getOtherStores = () => {
const { useMatchStore } = require("./matchStore");
const { usePacksStore } = require("./packsStore");
const { useMessageStore } = require("./messageStore");
const { useSubscriptionStore } = require("./subscriptionStore");
const { useNotificationPreferencesStore } = require("./notificationPreferencesStore");
const { useStreakStore } = require("./streakStore");
return { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore };
};
export const useAuthStore = create<AuthState>((set, get) => ({
user: null,
couple: null,
partner: null,
isLoading: true,
isAuthenticated: false,
isAnonymous: false,
fetchUser: async () => {
try {
// First check if we have a session in storage
import { Events } from "../lib/analytics";
import type { Profile, Couple } from "@/types";
interface AuthState {
user: Profile | null;
couple: Couple | null;
partner: Profile | null;
isLoading: boolean;
isAuthenticated: boolean;
isAnonymous: boolean;
// Actions
fetchUser: () => Promise<void>;
fetchCouple: () => Promise<void>;
refreshPartner: () => Promise<Profile | null>;
signOut: () => Promise<void>;
setUser: (user: Profile | null) => void;
updateLastActive: () => Promise<void>;
}
// Import other stores lazily to avoid circular dependency issues
const getOtherStores = () => {
const { useMatchStore } = require("./matchStore");
const { usePacksStore } = require("./packsStore");
const { useMessageStore } = require("./messageStore");
const { useSubscriptionStore } = require("./subscriptionStore");
const { useNotificationPreferencesStore } = require("./notificationPreferencesStore");
const { useStreakStore } = require("./streakStore");
const { useResponsesStore } = require("./responsesStore");
return { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore };
};
export const useAuthStore = create<AuthState>((set, get) => ({
user: null,
couple: null,
partner: null,
isLoading: true,
isAuthenticated: false,
isAnonymous: false,
fetchUser: async () => {
try {
// First check if we have a session in storage
const { data: { session } } = await authClient.auth.getSession();
if (!session?.user) {
set({ user: null, isAuthenticated: false, isAnonymous: false, isLoading: false });
return;
}
if (!session?.user) {
set({ user: null, isAuthenticated: false, isAnonymous: false, isLoading: false });
return;
}
// This endpoint verifies the Supabase access token and creates the
// product profile on first use, keeping profile data out of Supabase.
const { profile } = await apiClient.get<{ profile: Profile }>("/v1/me");
const isAnonymous = !!(session.user as { is_anonymous?: boolean }).is_anonymous;
// Only update user if data actually changed to avoid unnecessary re-renders
const currentUser = get().user;
const userChanged = !currentUser || JSON.stringify(currentUser) !== JSON.stringify(profile);
if (userChanged) {
set({
user: profile,
isAuthenticated: true,
isAnonymous,
});
} else if (!get().isAuthenticated) {
set({ isAuthenticated: true, isAnonymous });
}
// If user has a couple, fetch couple data; otherwise clear couple/partner
if (profile?.couple_id) {
await get().fetchCouple();
} else {
set({ couple: null, partner: null });
}
set({ isLoading: false });
// Only update user if data actually changed to avoid unnecessary re-renders
const currentUser = get().user;
const userChanged = !currentUser || JSON.stringify(currentUser) !== JSON.stringify(profile);
if (userChanged) {
set({
user: profile,
isAuthenticated: true,
isAnonymous,
});
} else if (!get().isAuthenticated) {
set({ isAuthenticated: true, isAnonymous });
}
// If user has a couple, fetch couple data; otherwise clear couple/partner
if (profile?.couple_id) {
await get().fetchCouple();
} else {
set({ couple: null, partner: null });
}
set({ isLoading: false });
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
console.log("[Auth] Session rejected by API, signing out");
set({ user: null, couple: null, partner: null, isAuthenticated: false, isAnonymous: false, isLoading: false });
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores();
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore } = getOtherStores();
useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks();
useMessageStore.getState().clearMessages();
useSubscriptionStore.getState().clearSubscription();
useNotificationPreferencesStore.getState().clearPreferences();
useStreakStore.getState().clearStreak();
useResponsesStore.getState().clearResponses();
await authClient.auth.signOut();
return;
}
console.error("Error fetching user:", error);
set({ isLoading: false });
}
},
}
},
fetchCouple: async () => {
const user = get().user;
if (!user?.couple_id) {
@@ -111,100 +113,102 @@ export const useAuthStore = create<AuthState>((set, get) => ({
const { couple, partner } = await coupleApi.getState();
set({ couple, partner });
},
/**
* Refresh partner's profile data (useful to get latest public_key_jwk for E2EE)
* Returns the updated partner profile or null if not found
*/
refreshPartner: async () => {
const user = get().user;
if (!user?.couple_id) return null;
},
/**
* Refresh partner's profile data (useful to get latest public_key_jwk for E2EE)
* Returns the updated partner profile or null if not found
*/
refreshPartner: async () => {
const user = get().user;
if (!user?.couple_id) return null;
const { partner } = await coupleApi.getState();
if (partner) {
set({ partner });
}
return partner;
},
signOut: async () => {
Events.signOut();
// Clear local state FIRST to ensure UI updates even if Supabase call fails
set({
user: null,
couple: null,
partner: null,
isAuthenticated: false,
isAnonymous: false,
isLoading: false,
});
// Clear other stores
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores();
useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks();
useMessageStore.getState().clearMessages();
useSubscriptionStore.getState().clearSubscription();
useNotificationPreferencesStore.getState().clearPreferences();
useStreakStore.getState().clearStreak();
// Clear badge on sign out
const { clearBadge } = require("../lib/badge");
await clearBadge();
// Then try to sign out from Supabase (don't block on this)
try {
if (partner) {
set({ partner });
}
return partner;
},
signOut: async () => {
Events.signOut();
// Clear local state FIRST to ensure UI updates even if Supabase call fails
set({
user: null,
couple: null,
partner: null,
isAuthenticated: false,
isAnonymous: false,
isLoading: false,
});
// Clear other stores
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore } = getOtherStores();
useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks();
useMessageStore.getState().clearMessages();
useSubscriptionStore.getState().clearSubscription();
useNotificationPreferencesStore.getState().clearPreferences();
useStreakStore.getState().clearStreak();
useResponsesStore.getState().clearResponses();
// Clear badge on sign out
const { clearBadge } = require("../lib/badge");
await clearBadge();
// Then try to sign out from Supabase (don't block on this)
try {
await authClient.auth.signOut();
} catch (error) {
console.error("Supabase signOut error:", error);
}
},
setUser: (user) => {
set({
user,
isAuthenticated: !!user,
...(user === null && { isAnonymous: false }),
isLoading: false,
// Clear couple/partner when user is null (signed out)
...(user === null && { couple: null, partner: null })
});
// Clear other stores when user signs out
if (user === null) {
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores();
useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks();
useMessageStore.getState().clearMessages();
useSubscriptionStore.getState().clearSubscription();
useNotificationPreferencesStore.getState().clearPreferences();
useStreakStore.getState().clearStreak();
// Clear badge on sign out
const { clearBadge } = require("../lib/badge");
clearBadge();
}
},
/**
* Update the user's last_active_at timestamp.
* Called when the app comes to the foreground to track user activity.
* This helps prevent sending notifications to users who are already in the app.
*/
updateLastActive: async () => {
const userId = get().user?.id;
if (!userId) return;
try {
} catch (error) {
console.error("Supabase signOut error:", error);
}
},
setUser: (user) => {
set({
user,
isAuthenticated: !!user,
...(user === null && { isAnonymous: false }),
isLoading: false,
// Clear couple/partner when user is null (signed out)
...(user === null && { couple: null, partner: null })
});
// Clear other stores when user signs out
if (user === null) {
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore } = getOtherStores();
useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks();
useMessageStore.getState().clearMessages();
useSubscriptionStore.getState().clearSubscription();
useNotificationPreferencesStore.getState().clearPreferences();
useStreakStore.getState().clearStreak();
useResponsesStore.getState().clearResponses();
// Clear badge on sign out
const { clearBadge } = require("../lib/badge");
clearBadge();
}
},
/**
* Update the user's last_active_at timestamp.
* Called when the app comes to the foreground to track user activity.
* This helps prevent sending notifications to users who are already in the app.
*/
updateLastActive: async () => {
const userId = get().user?.id;
if (!userId) return;
try {
await profileSettingsApi.updateLastActive();
} catch (error) {
// Silently fail - this is not critical
console.error("Failed to update last_active_at:", error);
}
// Keep the server's idea of the user's timezone current so the daily
// response limit resets at their local midnight, not 00:00 UTC.
void syncTimezone(userId);
},
}));
} catch (error) {
// Silently fail - this is not critical
console.error("Failed to update last_active_at:", error);
}
// Keep the server's idea of the user's timezone current so the daily
// response limit resets at their local midnight, not 00:00 UTC.
void syncTimezone(userId);
},
}));
+148 -53
View File
@@ -15,6 +15,20 @@ export interface PendingQuestion {
type MatchViewType = 'active' | 'archived' | 'pending' | 'their_turn';
/**
* A refresh that must not disturb what the user is looking at: no spinner, and
* the cached rows stay on screen until the replacement arrives.
*/
export interface FetchOptions { silent?: boolean }
// Overlap protection lives outside the store because the loading flags are now
// presentational — a silent refresh sets none of them and still must not run twice.
// Keyed to the generation that started the request (see `generation` below) rather
// than a bare presence check: a stale generation's entry must not block the next
// account's request for the same key, and a stale generation's `finally` must not
// clear an entry a newer generation just claimed.
const inFlight = new Map<string, number>();
// Rate limit: 12 hours in milliseconds
const NUDGE_COOLDOWN_MS = 12 * 60 * 60 * 1000;
@@ -32,6 +46,8 @@ interface MatchState {
newMatchesCount: number;
totalCount: number | null;
isLoading: boolean;
/** A user-initiated refresh over data already on screen; drives pull-to-refresh only. */
isRefreshing: boolean;
isLoadingMore: boolean;
page: number;
hasMore: boolean;
@@ -48,11 +64,23 @@ interface MatchState {
theirTurnQuestions: PendingQuestion[];
isLoadingTheirTurn: boolean;
currentView: MatchViewType;
/**
* When each view last loaded successfully. Tracked separately from the row
* arrays because an empty view is still a loaded view, and revisiting one
* must not refetch.
*/
loadedAt: Partial<Record<MatchViewType, number>>;
// Nudge state
nudgeCooldownUntil: Date | null;
isNudging: boolean;
/**
* Bumped on every clear (sign-out/account switch). A fetch captures this before
* it starts and checks it again before writing its result, so a response that
* outlives the clear cannot land in the next account's store.
*/
generation: number;
// Methods
fetchMatches: (refresh?: boolean) => Promise<void>;
fetchMatches: (refresh?: boolean, options?: FetchOptions) => Promise<void>;
markAsSeen: (matchId: string) => Promise<void>;
markAllAsSeen: () => Promise<void>;
addMatch: (match: Match) => void;
@@ -61,13 +89,14 @@ interface MatchState {
// Archive methods
archiveMatch: (matchId: string) => Promise<void>;
unarchiveMatch: (matchId: string) => Promise<void>;
fetchArchivedMatches: () => Promise<void>;
fetchArchivedMatches: (options?: FetchOptions) => Promise<void>;
toggleShowArchived: () => void;
isMatchArchived: (matchId: string) => boolean;
// Pending methods
fetchPendingQuestions: () => Promise<void>;
fetchTheirTurnQuestions: () => Promise<void>;
fetchPendingQuestions: (options?: FetchOptions) => Promise<void>;
fetchTheirTurnQuestions: (options?: FetchOptions) => Promise<void>;
setCurrentView: (view: MatchViewType) => void;
ensureViewLoaded: (view?: MatchViewType) => void;
// Nudge methods
sendNudge: () => Promise<{ success: boolean; notificationSent: boolean }>;
checkNudgeCooldown: () => Promise<void>;
@@ -78,6 +107,7 @@ export const useMatchStore = create<MatchState>((set, get) => ({
newMatchesCount: 0,
totalCount: null,
isLoading: false,
isRefreshing: false,
isLoadingMore: false,
page: 0,
hasMore: true,
@@ -94,40 +124,53 @@ export const useMatchStore = create<MatchState>((set, get) => ({
theirTurnQuestions: [],
isLoadingTheirTurn: false,
currentView: 'pending' as MatchViewType,
loadedAt: {},
// Nudge state
nudgeCooldownUntil: null,
isNudging: false,
generation: 0,
fetchMatches: async (refresh = false) => {
const userId = useAuthStore.getState().user?.id;
fetchMatches: async (refresh = false, options = {}) => {
const coupleId = useAuthStore.getState().user?.couple_id;
const myGeneration = get().generation;
// Early return if no couple - user isn't paired yet
if (!coupleId) {
set({ matches: [], newMatchesCount: 0, totalCount: 0, isLoading: false });
set({ matches: [], newMatchesCount: 0, totalCount: 0, isLoading: false, isRefreshing: false });
return;
}
const state = get();
if (state.isLoading || (state.isLoadingMore && !refresh)) return;
if (inFlight.get('active') === myGeneration || (state.isLoadingMore && !refresh)) return;
// Nothing on screen yet is the only case that warrants blanking the list
// for a spinner; every other refresh leaves the cached rows in place.
const initial = state.loadedAt.active === undefined;
if (refresh) {
set({ isLoading: true, error: null, page: 0, hasMore: true });
set({ isLoading: initial, isRefreshing: !initial && !options.silent, error: null, page: 0, hasMore: true });
} else {
if (!state.hasMore) return;
set({ isLoadingMore: true });
}
inFlight.set('active', myGeneration);
try {
const currentPage = refresh ? 0 : state.page;
const result = await apiClient.get<{ matches: Match[]; totalCount: number }>(`/v1/matches?page=${currentPage}&limit=${BATCH_SIZE}`);
const result = await apiClient.get<{ matches: Match[]; totalCount: number | null; hasMore?: boolean }>(`/v1/matches?page=${currentPage}&limit=${BATCH_SIZE}`);
// A sign-out/account switch that happened while this request was in
// flight must not let its response populate the next account's store.
if (get().generation !== myGeneration) return;
const nonArchivedMatches = result.matches;
const totalCount = result.totalCount;
const archivedMatchIds = state.archivedMatchIds;
// The API reports this by reading one row past the page; the length
// comparison is only a fallback for an older server.
const hasMore = result.hasMore ?? nonArchivedMatches.length === BATCH_SIZE;
const loadedAt = { ...state.loadedAt, active: Date.now() };
if (nonArchivedMatches.length === 0) {
if (refresh) {
set({ matches: [], newMatchesCount: 0, totalCount: totalCount ?? 0, isLoading: false, hasMore: false, archivedMatchIds });
set({ matches: [], newMatchesCount: 0, totalCount: totalCount ?? 0, isLoading: false, isRefreshing: false, hasMore: false, archivedMatchIds, loadedAt });
} else {
set({ isLoadingMore: false, hasMore: false });
}
@@ -139,7 +182,7 @@ export const useMatchStore = create<MatchState>((set, get) => ({
// Calculate new matches count - this is tricky with pagination
// Ideally we should have a separate query for count, but we'll just count in current batch
const newMatchesInBatch = sortedData.filter((m) => m.is_new).length;
set((state) => {
// Deduplicate matches by ID when appending new pages
const mergedMatches = refresh
@@ -154,22 +197,34 @@ export const useMatchStore = create<MatchState>((set, get) => ({
return {
matches: mergedMatches,
newMatchesCount: refresh ? newMatchesInBatch : state.newMatchesCount + newMatchesInBatch,
totalCount: refresh ? totalCount : state.totalCount,
// Later pages no longer carry a total, so keep the one the refresh recorded.
totalCount: refresh ? totalCount ?? state.totalCount : state.totalCount,
isLoading: false,
isRefreshing: false,
isLoadingMore: false,
page: currentPage + 1,
hasMore: nonArchivedMatches.length === BATCH_SIZE,
hasMore,
archivedMatchIds: refresh ? archivedMatchIds : state.archivedMatchIds,
loadedAt,
};
});
} catch (err) {
if (get().generation !== myGeneration) return;
console.error("Error fetching matches:", err);
set({ error: "Failed to load matches", isLoading: false, isLoadingMore: false });
set({ error: "Failed to load matches", isLoading: false, isRefreshing: false, isLoadingMore: false });
} finally {
// An old generation's cleanup must not delete a newer generation's guard.
if (inFlight.get('active') === myGeneration) inFlight.delete('active');
}
},
markAsSeen: async (matchId) => {
const myGeneration = get().generation;
await apiClient.patch("/v1/matches/seen", { ids: [matchId] });
// A clear while this was in flight leaves an empty list behind, and the
// recount below would write that emptiness — and a zero badge — over
// whatever the next account has already loaded.
if (get().generation !== myGeneration) return;
const matches = get().matches.map((m) =>
m.id === matchId ? { ...m, is_new: false } : m
@@ -179,11 +234,13 @@ export const useMatchStore = create<MatchState>((set, get) => ({
},
markAllAsSeen: async () => {
const myGeneration = get().generation;
const newMatches = get().matches.filter((m) => m.is_new);
if (newMatches.length === 0) return;
const newMatchIds = newMatches.map((m) => m.id);
await apiClient.patch("/v1/matches/seen", { ids: newMatchIds });
if (get().generation !== myGeneration) return;
const matches = get().matches.map((m) =>
m.is_new ? { ...m, is_new: false } : m
@@ -210,11 +267,12 @@ export const useMatchStore = create<MatchState>((set, get) => ({
},
clearMatches: () => {
set({
set((state) => ({
matches: [],
newMatchesCount: 0,
totalCount: null,
isLoading: false,
isRefreshing: false,
error: null,
page: 0,
hasMore: true,
@@ -228,9 +286,16 @@ clearMatches: () => {
theirTurnQuestions: [],
isLoadingTheirTurn: false,
currentView: 'pending',
loadedAt: {},
nudgeCooldownUntil: null,
isNudging: false,
});
// Invalidates any request already in flight: its generation check will
// fail and it will not be able to write into the next account's store.
generation: state.generation + 1,
}));
// A request belonging to the signed-out user must not suppress the next
// one: the guard is a duplicate-request check, not a lock on the data.
inFlight.clear();
},
// Archive methods
@@ -240,6 +305,7 @@ clearMatches: () => {
// Optimistically update UI
const state = get();
const myGeneration = state.generation;
const matchToArchive = state.matches.find(m => m.id === matchId);
if (matchToArchive) {
@@ -258,7 +324,11 @@ clearMatches: () => {
await apiClient.put(`/v1/matches/${matchId}/archive`, { archived: true });
} catch (error) {
console.error("Error archiving match:", error);
// Revert on error
// Revert on error — but only into the store the optimistic update was
// applied to. After an account switch the row it would restore belongs
// to the previous couple, and putting it back would show one couple's
// match inside another's list.
if (get().generation !== myGeneration) return;
if (matchToArchive) {
const revertArchivedIds = new Set(get().archivedMatchIds);
revertArchivedIds.delete(matchId);
@@ -279,6 +349,7 @@ clearMatches: () => {
// Optimistically update UI
const state = get();
const myGeneration = state.generation;
const matchToUnarchive = state.archivedMatches.find(m => m.id === matchId);
if (matchToUnarchive) {
@@ -297,7 +368,8 @@ clearMatches: () => {
await apiClient.put(`/v1/matches/${matchId}/archive`, { archived: false });
} catch (error) {
console.error("Error unarchiving match:", error);
// Revert on error
// Revert on error, and only for the account that started it.
if (get().generation !== myGeneration) return;
if (matchToUnarchive) {
const revertArchivedIds = new Set(get().archivedMatchIds);
revertArchivedIds.add(matchId);
@@ -312,34 +384,35 @@ clearMatches: () => {
}
},
fetchArchivedMatches: async () => {
fetchArchivedMatches: async (options = {}) => {
const userId = useAuthStore.getState().user?.id;
const coupleId = useAuthStore.getState().user?.couple_id;
if (!coupleId || !userId) return;
const myGeneration = get().generation;
const state = get();
if (state.isLoadingArchived) return;
set({ isLoadingArchived: true });
if (inFlight.get('archived') === myGeneration) return;
if (!options.silent || state.loadedAt.archived === undefined) set({ isLoadingArchived: true });
inFlight.set('archived', myGeneration);
try {
const result = await apiClient.get<{matches:Match[];totalCount:number}>(`/v1/matches?archived=true&limit=100`);
const result = await apiClient.get<{matches:Match[];totalCount:number | null}>(`/v1/matches?archived=true&limit=100`);
if (get().generation !== myGeneration) return;
const archivedMatches = result.matches;
const archivedIds = archivedMatches.map(match => match.id);
if (archivedMatches.length === 0) {
set({ archivedMatches: [], isLoadingArchived: false });
return;
}
set({
set((current) => ({
archivedMatches,
isLoadingArchived: false,
archivedMatchIds: new Set(archivedIds),
});
loadedAt: { ...current.loadedAt, archived: Date.now() },
}));
} catch (err) {
if (get().generation !== myGeneration) return;
console.error("Error fetching archived matches:", err);
set({ isLoadingArchived: false });
} finally {
if (inFlight.get('archived') === myGeneration) inFlight.delete('archived');
}
},
@@ -350,8 +423,8 @@ clearMatches: () => {
set({ showArchived: newShowArchived });
// Fetch archived matches if switching to archived view and not yet loaded
if (newShowArchived && state.archivedMatches.length === 0) {
get().fetchArchivedMatches();
if (newShowArchived && state.loadedAt.archived === undefined) {
void get().fetchArchivedMatches();
}
},
@@ -360,7 +433,7 @@ clearMatches: () => {
},
// Pending (Your Turn) methods
fetchPendingQuestions: async () => {
fetchPendingQuestions: async (options = {}) => {
const userId = useAuthStore.getState().user?.id;
const coupleId = useAuthStore.getState().user?.couple_id;
@@ -369,22 +442,27 @@ clearMatches: () => {
return;
}
const myGeneration = get().generation;
const state = get();
if (state.isLoadingPending) return;
set({ isLoadingPending: true });
if (inFlight.get('pending') === myGeneration) return;
if (!options.silent || state.loadedAt.pending === undefined) set({ isLoadingPending: true });
inFlight.set('pending', myGeneration);
try {
const { questions: pendingQuestions } = await apiClient.get<{questions:PendingQuestion[]}>("/v1/questions/pending?direction=partner");
set({ pendingQuestions, isLoadingPending: false });
if (get().generation !== myGeneration) return;
set((current) => ({ pendingQuestions, isLoadingPending: false, loadedAt: { ...current.loadedAt, pending: Date.now() } }));
} catch (err) {
if (get().generation !== myGeneration) return;
console.error("Error fetching pending questions:", err);
set({ isLoadingPending: false });
} finally {
if (inFlight.get('pending') === myGeneration) inFlight.delete('pending');
}
},
// Their Turn methods (user answered, partner hasn't yet)
fetchTheirTurnQuestions: async () => {
fetchTheirTurnQuestions: async (options = {}) => {
const userId = useAuthStore.getState().user?.id;
const coupleId = useAuthStore.getState().user?.couple_id;
@@ -393,35 +471,43 @@ clearMatches: () => {
return;
}
const myGeneration = get().generation;
const state = get();
if (state.isLoadingTheirTurn) return;
set({ isLoadingTheirTurn: true });
if (inFlight.get('their_turn') === myGeneration) return;
if (!options.silent || state.loadedAt.their_turn === undefined) set({ isLoadingTheirTurn: true });
inFlight.set('their_turn', myGeneration);
try {
const { questions: theirTurnQuestions } = await apiClient.get<{questions:PendingQuestion[]}>("/v1/questions/pending?direction=mine");
set({ theirTurnQuestions, isLoadingTheirTurn: false });
if (get().generation !== myGeneration) return;
set((current) => ({ theirTurnQuestions, isLoadingTheirTurn: false, loadedAt: { ...current.loadedAt, their_turn: Date.now() } }));
} catch (err) {
if (get().generation !== myGeneration) return;
console.error("Error fetching their turn questions:", err);
set({ isLoadingTheirTurn: false });
} finally {
if (inFlight.get('their_turn') === myGeneration) inFlight.delete('their_turn');
}
},
setCurrentView: (view: MatchViewType) => {
const state = get();
// Update showArchived for backwards compatibility
const showArchived = view === 'archived';
set({ currentView: view, showArchived });
// Fetch data for the selected view if not already loaded
if (view === 'archived' && state.archivedMatches.length === 0) {
get().fetchArchivedMatches();
} else if (view === 'pending' && state.pendingQuestions.length === 0) {
get().fetchPendingQuestions();
} else if (view === 'their_turn' && state.theirTurnQuestions.length === 0) {
get().fetchTheirTurnQuestions();
}
get().ensureViewLoaded(view);
},
/**
* Loads a view once. Emptiness is not evidence of never having loaded, so
* returning to a legitimately empty view costs no request at all.
*/
ensureViewLoaded: (view = get().currentView) => {
if (get().loadedAt[view] !== undefined) return;
if (view === 'archived') void get().fetchArchivedMatches();
else if (view === 'pending') void get().fetchPendingQuestions();
else if (view === 'their_turn') void get().fetchTheirTurnQuestions();
else void get().fetchMatches(true);
},
// Nudge methods
@@ -436,10 +522,14 @@ clearMatches: () => {
return { success: false, notificationSent: false };
}
const myGeneration = state.generation;
set({ isNudging: true });
try {
const data = await appDataApi.sendNudge();
// The cooldown is per-account. Recording the previous account's window
// would either silence the new account's nudge or clear a real cooldown.
if (get().generation !== myGeneration) return { success: false, notificationSent: false };
// Success - update cooldown
const cooldownUntil = data.next_nudge_available_at
@@ -453,6 +543,7 @@ clearMatches: () => {
notificationSent: data.notification_sent ?? false,
};
} catch (err) {
if (get().generation !== myGeneration) return { success: false, notificationSent: false };
if (err instanceof ApiError && err.status === 429) {
const details = err.details as NudgeResponse | undefined;
const cooldownUntil = details?.next_nudge_available_at
@@ -471,8 +562,12 @@ clearMatches: () => {
const userId = useAuthStore.getState().user?.id;
if (!userId) return;
const myGeneration = get().generation;
try {
const profile = await appDataApi.nudgeStatus();
// Read for the previous account: neither its cooldown nor its absence
// says anything about whether this one may nudge.
if (get().generation !== myGeneration) return;
if (!profile.last_nudge_sent_at) {
set({ nudgeCooldownUntil: null });
+106 -81
View File
@@ -1,85 +1,110 @@
import { create } from "zustand";
import { create } from "zustand";
import { chatApi } from "../lib/chatApi";
import type { Database } from "@/types/supabase";
import { useAuthStore } from "./authStore";
import { useMatchStore } from "./matchStore";
type Message = Database["public"]["Tables"]["messages"]["Row"];
interface MessageWithMatch extends Message {
match?: {
id: string;
question: {
text: string;
};
};
}
interface MessageState {
unreadCount: number;
lastMessage: MessageWithMatch | null;
activeMatchId: string | null; // Track which chat is currently open
fetchUnreadCount: () => Promise<void>;
addMessage: (message: MessageWithMatch) => void;
clearLastMessage: () => void;
setActiveMatchId: (matchId: string | null) => void;
markMatchMessagesAsRead: (matchId: string) => Promise<void>;
clearMessages: () => void;
}
export const useMessageStore = create<MessageState>((set, get) => ({
unreadCount: 0,
lastMessage: null,
activeMatchId: null,
fetchUnreadCount: async () => {
const userId = useAuthStore.getState().user?.id;
if (!userId) {
set({ unreadCount: 0 });
return;
}
import type { Database } from "@/types/supabase";
import { useAuthStore } from "./authStore";
import { useMatchStore } from "./matchStore";
type Message = Database["public"]["Tables"]["messages"]["Row"];
interface MessageWithMatch extends Message {
match?: {
id: string;
question: {
text: string;
};
};
}
interface MessageState {
unreadCount: number;
lastMessage: MessageWithMatch | null;
activeMatchId: string | null; // Track which chat is currently open
/**
* Bumped by `clearMessages` on sign-out or a couple change, so a reply that
* outlives the account that asked for it cannot write into the next one's badge.
*/
generation: number;
fetchUnreadCount: () => Promise<void>;
/** Applies a count the sync summary already reported, without a second request. */
setUnreadCount: (total: number) => void;
addMessage: (message: MessageWithMatch) => void;
clearLastMessage: () => void;
setActiveMatchId: (matchId: string | null) => void;
markMatchMessagesAsRead: (matchId: string) => Promise<void>;
clearMessages: () => void;
}
export const useMessageStore = create<MessageState>((set, get) => ({
unreadCount: 0,
lastMessage: null,
activeMatchId: null,
generation: 0,
fetchUnreadCount: async () => {
const userId = useAuthStore.getState().user?.id;
if (!userId) {
set({ unreadCount: 0 });
return;
}
const myGeneration = get().generation;
const { total } = await chatApi.unread();
// This total counts one account's messages; after a clear it would show
// up as the next account's badge.
if (get().generation !== myGeneration) return;
set({ unreadCount: total });
},
addMessage: (message) => {
const userId = useAuthStore.getState().user?.id;
const activeMatchId = get().activeMatchId;
// Only show notification if message is from partner and not in the active chat
if (message.user_id !== userId && message.match_id !== activeMatchId) {
set((state) => ({
unreadCount: state.unreadCount + 1,
lastMessage: message,
}));
}
},
clearLastMessage: () => {
set({ lastMessage: null });
},
setActiveMatchId: (matchId) => {
set({ activeMatchId: matchId });
},
markMatchMessagesAsRead: async (matchId) => {
const userId = useAuthStore.getState().user?.id;
if (!userId) return;
},
setUnreadCount: (total) => {
set({ unreadCount: Math.max(0, total) });
},
addMessage: (message) => {
const userId = useAuthStore.getState().user?.id;
const activeMatchId = get().activeMatchId;
// Only show notification if message is from partner and not in the active chat
if (message.user_id !== userId && message.match_id !== activeMatchId) {
set((state) => ({
unreadCount: state.unreadCount + 1,
lastMessage: message,
}));
}
},
clearLastMessage: () => {
set({ lastMessage: null });
},
setActiveMatchId: (matchId) => {
set({ activeMatchId: matchId });
},
markMatchMessagesAsRead: async (matchId) => {
const userId = useAuthStore.getState().user?.id;
if (!userId) return;
const myGeneration = get().generation;
const { updated: unreadInMatch } = await chatApi.markRead(matchId);
// Refetch global unread count
await get().fetchUnreadCount();
// Sync per-match unread count in matchStore
if (unreadInMatch > 0) {
useMatchStore.getState().updateMatchUnreadCount(matchId, -unreadInMatch);
}
},
clearMessages: () => {
set({ unreadCount: 0, lastMessage: null, activeMatchId: null });
},
}));
if (unreadInMatch <= 0) return;
// Those messages belonged to a couple that is no longer loaded, and the
// match they were in is not in the current list, so subtracting them now
// would take the old badge off the new account's.
if (get().generation !== myGeneration) return;
// The server just told us exactly how many it cleared, so subtract instead
// of spending a request to re-read the total; the sync summary corrects
// any drift on its next pass.
set((state) => ({ unreadCount: Math.max(0, state.unreadCount - unreadInMatch) }));
useMatchStore.getState().updateMatchUnreadCount(matchId, -unreadInMatch);
},
clearMessages: () => {
set((state) => ({
unreadCount: 0, lastMessage: null, activeMatchId: null,
// Invalidates any request already in flight for the account being left.
generation: state.generation + 1,
}));
},
}));
+68 -6
View File
@@ -4,6 +4,12 @@ import { Events } from "../lib/analytics";
import type { QuestionPack, Category } from "@/types";
import { useAuthStore } from "./authStore";
// The catalog load fans out into three requests, so a second caller arriving mid-flight
// must wait for the first rather than triple the cost. Keyed to the generation that
// started the request: a stale generation's entry must not block the next account's
// request for the same key, and its `finally` must not clear a newer generation's guard.
const inFlight = new Map<string, number>();
// Progress data for a pack
export interface PackProgressData {
totalQuestions: number;
@@ -16,10 +22,23 @@ interface PacksState {
enabledPackIds: string[];
packProgress: Map<string, PackProgressData>;
isLoading: boolean;
/**
* When the catalog, enabled set and progress were last read together. Null means
* never — which is not the same as "empty", so a couple with no packs enabled
* stops refetching after the first load.
*/
catalogLoadedAt: number | null;
/** Tracked separately because the swipe screen loads the enabled set on its own. */
enabledPacksLoadedAt: number | null;
/** Bumped on clear/sign-out so a response that outlives it cannot write into the next account's store. */
generation: number;
fetchPacks: () => Promise<void>;
fetchEnabledPacks: () => Promise<void>;
fetchPackProgress: () => Promise<void>;
ensurePacksLoaded: () => Promise<void>;
ensureEnabledPacksLoaded: () => Promise<void>;
/** Marks the cached catalog stale without spending a request; the next ensure reloads it. */
invalidatePacks: () => void;
togglePack: (packId: string) => Promise<{ success: boolean; reason?: string }>;
clearPacks: () => void;
getPackProgress: (packId: string) => PackProgressData | undefined;
@@ -31,38 +50,61 @@ export const usePacksStore = create<PacksState>((set, get) => ({
enabledPackIds: [],
packProgress: new Map(),
isLoading: false,
catalogLoadedAt: null,
enabledPacksLoadedAt: null,
generation: 0,
fetchPacks: async () => {
const myGeneration = get().generation;
if (inFlight.get('catalog') === myGeneration) return;
inFlight.set('catalog', myGeneration);
set({ isLoading: true });
try {
const catalog = await apiClient.get<{ categories: Category[]; packs: QuestionPack[] }>(
'/v1/packs?showAllIntensities=false',
);
if (get().generation !== myGeneration) return;
set({ packs: catalog.packs, categories: catalog.categories });
await get().fetchEnabledPacks();
await get().fetchPackProgress();
if (get().generation !== myGeneration) return;
set({ catalogLoadedAt: Date.now() });
} finally {
set({ isLoading: false });
if (inFlight.get('catalog') === myGeneration) inFlight.delete('catalog');
if (get().generation === myGeneration) set({ isLoading: false });
}
},
ensurePacksLoaded: async () => {
if (get().catalogLoadedAt !== null) return;
await get().fetchPacks();
},
invalidatePacks: () => {
set({ catalogLoadedAt: null });
},
fetchEnabledPacks: async () => {
const coupleId = useAuthStore.getState().user?.couple_id;
const myGeneration = get().generation;
if (!coupleId) {
set({ enabledPackIds: [] });
set({ enabledPackIds: [], enabledPacksLoadedAt: null });
return;
}
const result = await apiClient.get<{ enabledPackIds: string[] }>("/v1/me/enabled-packs");
set({ enabledPackIds: result.enabledPackIds });
if (get().generation !== myGeneration) return;
set({ enabledPackIds: result.enabledPackIds, enabledPacksLoadedAt: Date.now() });
},
ensureEnabledPacksLoaded: async () => {
// Only fetch if not already loaded - lightweight check for swipe screen
// Only fetch if not already loaded - lightweight check for swipe screen.
// Keyed off the load marker, not the array: "no packs enabled" is a real
// answer and must not be retried on every visit.
const coupleId = useAuthStore.getState().user?.couple_id;
if (!coupleId) return;
if (get().enabledPackIds.length === 0) {
if (get().enabledPacksLoadedAt === null) {
await get().fetchEnabledPacks();
}
},
@@ -73,6 +115,7 @@ export const usePacksStore = create<PacksState>((set, get) => ({
return { success: false, reason: "no_couple" };
}
const myGeneration = get().generation;
const isEnabled = get().enabledPackIds.includes(packId);
const newValue = !isEnabled;
@@ -88,9 +131,16 @@ export const usePacksStore = create<PacksState>((set, get) => ({
`/v1/me/enabled-packs/${packId}`,
{ enabled: newValue },
);
// These ids belong to the couple that asked; a clear while the request
// was open means they would land in a different couple's selection.
if (get().generation !== myGeneration) return { success: false, reason: "stale" };
set({ enabledPackIds: result.enabledPackIds });
} catch (error) {
console.error("Error toggling pack:", error);
// The optimistic update this reverts was cleared along with everything
// else, so reverting now would only push a stale pack id into the next
// couple's list.
if (get().generation !== myGeneration) return { success: false, reason: "stale" };
set({ enabledPackIds: isEnabled
? [...new Set([...get().enabledPackIds, packId])]
: get().enabledPackIds.filter(id => id !== packId) });
@@ -108,6 +158,7 @@ export const usePacksStore = create<PacksState>((set, get) => ({
},
fetchPackProgress: async () => {
const myGeneration = get().generation;
if (!useAuthStore.getState().user?.id) {
set({ packProgress: new Map() });
return;
@@ -115,6 +166,7 @@ export const usePacksStore = create<PacksState>((set, get) => ({
const result = await apiClient.get<{ progress: Array<PackProgressData & { packId: string }> }>(
"/v1/me/pack-progress",
);
if (get().generation !== myGeneration) return;
const progressMap = new Map<string, PackProgressData>();
result.progress.forEach(({ packId, totalQuestions, answeredQuestions }) => {
progressMap.set(packId, { totalQuestions, answeredQuestions });
@@ -128,6 +180,16 @@ export const usePacksStore = create<PacksState>((set, get) => ({
},
clearPacks: () => {
set({ enabledPackIds: [], packProgress: new Map() });
set((state) => ({
enabledPackIds: [],
packProgress: new Map(),
catalogLoadedAt: null,
enabledPacksLoadedAt: null,
// Invalidates any request already in flight for the signed-out user.
generation: state.generation + 1,
}));
// See matchStore.clearMatches: the previous user's in-flight catalog read
// must not block the next user's.
inFlight.clear();
},
}));
+63 -7
View File
@@ -36,16 +36,33 @@ export type DateSortOrder = "newest" | "oldest";
const BATCH_SIZE = 20;
// Overlap protection independent of the presentational flags: a refresh over cached
// rows raises `isRefreshing`, not `isLoading`, and still must not run twice. Keyed to
// the generation that started the request: a stale generation's entry must not block
// the next account's request, and its `finally` must not clear a newer generation's guard.
const inFlight = new Map<string, number>();
interface ResponsesState {
responses: ResponseWithQuestion[];
/** First load only. A refresh over rows already on screen must not blank the list. */
isLoading: boolean;
/** Refresh over cached rows; drives pull-to-refresh only. */
isRefreshing: boolean;
isLoadingMore: boolean;
groupBy: GroupByOption;
dateSortOrder: DateSortOrder;
hasMore: boolean;
page: number;
totalCount: number | null;
/** Null until the first successful read; an empty history is a loaded answer. */
loadedAt: number | null;
/** Bumped on clear/sign-out so a response that outlives it cannot write into the next account's store. */
generation: number;
fetchResponses: (refresh?: boolean) => Promise<void>;
/** Loads the first page once; revisiting the screen reuses what is cached. */
ensureResponsesLoaded: () => Promise<void>;
/** Marks the cache stale without spending a request; the next ensure reloads it. */
invalidateResponses: () => void;
updateResponse: (
questionId: string,
newAnswer: AnswerType,
@@ -61,12 +78,15 @@ interface ResponsesState {
export const useResponsesStore = create<ResponsesState>((set, get) => ({
responses: [],
isLoading: false,
isRefreshing: false,
isLoadingMore: false,
groupBy: "date",
dateSortOrder: "newest",
hasMore: true,
page: 0,
totalCount: null,
loadedAt: null,
generation: 0,
fetchResponses: async (refresh = false) => {
const userId = useAuthStore.getState().user?.id;
@@ -74,25 +94,31 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
if (!userId || !coupleId) return;
const myGeneration = get().generation;
const state = get();
if (state.isLoading || (state.isLoadingMore && !refresh)) return;
if (inFlight.get('responses') === myGeneration || (state.isLoadingMore && !refresh)) return;
// Nothing on screen yet is the only case that warrants a full-screen spinner;
// every other refresh leaves the cached rows in place until replacements land.
const initial = state.loadedAt === null;
if (refresh) {
set({ isLoading: true, page: 0, hasMore: true });
set({ isLoading: initial, isRefreshing: !initial, page: 0, hasMore: true });
} else {
if (!state.hasMore) return;
set({ isLoadingMore: true });
}
inFlight.set('responses', myGeneration);
try {
const currentPage = refresh ? 0 : state.page;
const result = await apiClient.get<{responses: ResponseWithQuestion[];totalCount:number}>(`/v1/me/responses?page=${currentPage}&limit=${BATCH_SIZE}`);
if (get().generation !== myGeneration) return;
const responses = result.responses;
const totalCount = result.totalCount;
if (!responses || responses.length === 0) {
if (refresh) {
set({ responses: [], totalCount: totalCount ?? 0, isLoading: false, hasMore: false });
set({ responses: [], totalCount: totalCount ?? 0, isLoading: false, isRefreshing: false, hasMore: false, loadedAt: Date.now() });
} else {
set({ isLoadingMore: false, hasMore: false });
}
@@ -100,29 +126,44 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
}
const transformedResponses = responses;
set((state) => ({
responses: refresh
? transformedResponses
: [...state.responses, ...transformedResponses],
totalCount: refresh ? totalCount : state.totalCount,
isLoading: false,
isRefreshing: false,
isLoadingMore: false,
page: currentPage + 1,
hasMore: responses.length === BATCH_SIZE
hasMore: responses.length === BATCH_SIZE,
loadedAt: Date.now(),
}));
} catch (error) {
if (get().generation !== myGeneration) return;
console.error("Error in fetchResponses:", error);
set({ isLoading: false, isLoadingMore: false });
set({ isLoading: false, isRefreshing: false, isLoadingMore: false });
} finally {
if (inFlight.get('responses') === myGeneration) inFlight.delete('responses');
}
},
ensureResponsesLoaded: async () => {
if (get().loadedAt !== null) return;
await get().fetchResponses(true);
},
invalidateResponses: () => {
set({ loadedAt: null });
},
updateResponse: async (
questionId: string,
newAnswer: AnswerType,
confirmDelete = false,
responseData?: ResponseData | null
): Promise<UpdateResponseResult> => {
const myGeneration = get().generation;
try {
const result = await apiClient.patch<UpdateResponseResult>(`/v1/responses/${questionId}`, {
new_answer: newAnswer,
@@ -130,6 +171,13 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
response_data: responseData,
});
// Rows are keyed by a question id from the shared catalogue, so once the
// store has been cleared that same id can name a different account's
// answer. Hand the caller the server's result — it is true of whoever
// asked — but write nothing, and refresh no matches, for the account
// that is loaded now.
if (get().generation !== myGeneration) return result;
// If update was successful (not just requiring confirmation), update local state
if (result.success && !result.requires_confirmation) {
// Update local response state
@@ -191,7 +239,15 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
},
clearResponses: () => {
set({ responses: [], isLoading: false, groupBy: "date", dateSortOrder: "newest", page: 0, hasMore: true, isLoadingMore: false, totalCount: null });
set((state) => ({
responses: [], isLoading: false, isRefreshing: false, groupBy: "date", dateSortOrder: "newest",
page: 0, hasMore: true, isLoadingMore: false, totalCount: null, loadedAt: null,
// Invalidates any request already in flight for the signed-out user.
generation: state.generation + 1,
}));
// See matchStore.clearMatches: the previous user's in-flight page must not
// block the next user's.
inFlight.clear();
},
}));
+109 -61
View File
@@ -1,62 +1,110 @@
import { create } from "zustand";
import { create } from "zustand";
import { apiClient } from "../lib/apiClient";
import { useAuthStore } from "./authStore";
/**
* Resolved from the signed-in partner's point of view by the API. The stored row is
* positional (user1/user2 by member id), which a client cannot act on without
* re-deriving that ordering, so the endpoint answers in you/partner terms instead.
*/
export interface CoupleStreak {
couple_id: string;
current_streak: number;
longest_streak: number;
last_active_date: string | null;
last_completed_date: string | null;
you_answered_today: boolean;
partner_answered_today: boolean;
partner_name: string | null;
/** The couple's shared IANA zone, which is the day boundary the streak is counted in. */
timezone: string;
streak_celebrated_at: number;
created_at: string;
updated_at: string;
}
interface StreakState {
streak: CoupleStreak | null;
isLoading: boolean;
error: string | null;
// Actions
fetchStreak: () => Promise<void>;
clearStreak: () => void;
}
export const useStreakStore = create<StreakState>((set) => ({
streak: null,
isLoading: false,
error: null,
fetchStreak: async () => {
const coupleId = useAuthStore.getState().user?.couple_id;
if (!coupleId) {
set({ streak: null, isLoading: false });
return;
}
set({ isLoading: true, error: null });
try {
const { streak } = await apiClient.get<{streak: CoupleStreak | null}>("/v1/me/streak");
set({ streak, isLoading: false });
} catch (error) {
console.error("Error fetching streak:", error);
set({ error: "Failed to load streak", isLoading: false });
}
},
clearStreak: () => {
set({ streak: null, isLoading: false, error: null });
},
}));
import { useAuthStore } from "./authStore";
/**
* Resolved from the signed-in partner's point of view by the API. The stored row is
* positional (user1/user2 by member id), which a client cannot act on without
* re-deriving that ordering, so the endpoint answers in you/partner terms instead.
*/
export interface CoupleStreak {
couple_id: string;
current_streak: number;
longest_streak: number;
last_active_date: string | null;
last_completed_date: string | null;
you_answered_today: boolean;
partner_answered_today: boolean;
partner_name: string | null;
/** The couple's shared IANA zone, which is the day boundary the streak is counted in. */
timezone: string;
streak_celebrated_at: number;
created_at: string;
updated_at: string;
}
interface StreakState {
streak: CoupleStreak | null;
isLoading: boolean;
error: string | null;
/**
* Null until the first successful read. A couple with no streak yet resolves to a
* null `streak`, which is a loaded answer rather than a reason to ask again.
*/
loadedAt: number | null;
/** Bumped on clear/sign-out so a response that outlives it cannot write into the next account's store. */
generation: number;
// Actions
fetchStreak: () => Promise<void>;
/** Loads the streak once; later callers reuse the cached value. */
ensureStreakLoaded: () => Promise<void>;
/** Silent re-read of an already-loaded streak; leaves the cached value on screen if it fails. */
refreshStreak: () => Promise<void>;
clearStreak: () => void;
}
// The streak is rendered by a badge, the home screen and the swipe screen, so
// concurrent mounts must share one request rather than each issuing their own.
// Tagged with the generation that started it: clearing/signing out bumps the
// generation, so a stale response can no longer write into the next account's
// store, and a stale `finally` cannot clear a newer generation's guard.
let inFlight: { generation: number; promise: Promise<void> } | null = null;
export const useStreakStore = create<StreakState>((set, get) => ({
streak: null,
isLoading: false,
error: null,
loadedAt: null,
generation: 0,
fetchStreak: async () => {
const coupleId = useAuthStore.getState().user?.couple_id;
const myGeneration = get().generation;
if (!coupleId) {
set({ streak: null, isLoading: false, loadedAt: null });
return;
}
if (inFlight && inFlight.generation === myGeneration) return inFlight.promise;
set({ isLoading: true, error: null });
const promise = (async () => {
try {
const { streak } = await apiClient.get<{streak: CoupleStreak | null}>("/v1/me/streak");
if (get().generation !== myGeneration) return;
set({ streak, isLoading: false, loadedAt: Date.now() });
} catch (error) {
if (get().generation !== myGeneration) return;
console.error("Error fetching streak:", error);
set({ error: "Failed to load streak", isLoading: false });
} finally {
if (inFlight?.generation === myGeneration) inFlight = null;
}
})();
inFlight = { generation: myGeneration, promise };
return promise;
},
ensureStreakLoaded: async () => {
if (get().loadedAt !== null) return;
await get().fetchStreak();
},
refreshStreak: async () => {
// Only silently refresh a streak the UI has actually loaded; an unopened
// screen has nothing cached to keep honest.
if (get().loadedAt === null) return;
await get().fetchStreak();
},
clearStreak: () => {
set((state) => ({
streak: null, isLoading: false, error: null, loadedAt: null,
// Invalidates any request already in flight for the signed-out user.
generation: state.generation + 1,
}));
},
}));