mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-01 18:19:43 +00:00
feat(engagement): localize limits and streaks
Use reported user timezones for daily response limits, expose couple streak state and reminders, and improve swipe and live-drawing synchronization with focused tests and documentation.
This commit is contained in:
1 parent
61ec3692b7
commit
b60c788362
42 files changed
+1308
-217
No files matched your search
@@ -44,8 +44,9 @@ export const LiveDrawScreen: React.FC = () => {
|
||||
const [shareDrawingUri, setShareDrawingUri] = useState<string | null>(null);
|
||||
|
||||
const canvasRef = useRef<DrawingCanvasHandle>(null);
|
||||
// Keep track of the current stroke id for sync broadcasts
|
||||
const currentStrokeIdRef = useRef<string | null>(null);
|
||||
// The stroke under the finger. Points are pushed in place — nothing about the
|
||||
// in-progress stroke goes through React state until it is finished.
|
||||
const currentStrokeRef = useRef<StrokeSegment | null>(null);
|
||||
|
||||
const canvasWidth = screenWidth - spacing.md * 2;
|
||||
const canvasHeight = canvasWidth * 1.2;
|
||||
@@ -64,6 +65,16 @@ export const LiveDrawScreen: React.FC = () => {
|
||||
|
||||
const { makeSnapshot, captureToFile } = useCanvasCapture();
|
||||
|
||||
// Mirrors of state read inside the touch handlers. Keeping them in refs means
|
||||
// the handlers (and therefore the pan gesture) keep a stable identity instead
|
||||
// of being rebuilt mid-stroke on every render.
|
||||
const strokesRef = useRef(strokes);
|
||||
strokesRef.current = strokes;
|
||||
const strokeStyleRef = useRef({ color: selectedColor, width: brushWidth, isEraser });
|
||||
strokeStyleRef.current = { color: selectedColor, width: brushWidth, isEraser };
|
||||
const userIdRef = useRef(user?.id);
|
||||
userIdRef.current = user?.id;
|
||||
|
||||
const handleStrokeStartFromPartner = useCallback(
|
||||
(stroke: StrokeSegment) => addStroke(stroke),
|
||||
[addStroke]
|
||||
@@ -95,57 +106,66 @@ export const LiveDrawScreen: React.FC = () => {
|
||||
|
||||
const handleTouchStart = useCallback(
|
||||
(point: StrokePoint) => {
|
||||
if (!user?.id) return;
|
||||
if (strokes.length >= MAX_STROKES) {
|
||||
const userId = userIdRef.current;
|
||||
// The gesture starts the live path on the UI thread before we get here,
|
||||
// so a refused stroke has to be wiped rather than simply not started.
|
||||
if (!userId) {
|
||||
canvasRef.current?.clearLiveStroke();
|
||||
return;
|
||||
}
|
||||
if (strokesRef.current.length >= MAX_STROKES) {
|
||||
canvasRef.current?.clearLiveStroke();
|
||||
Alert.alert('Canvas Full', 'Save your drawing and start fresh.');
|
||||
return;
|
||||
}
|
||||
|
||||
const style = strokeStyleRef.current;
|
||||
const stroke: StrokeSegment = {
|
||||
id: `${user.id}_${Date.now()}`,
|
||||
userId: user.id,
|
||||
id: `${userId}_${Date.now()}`,
|
||||
userId,
|
||||
points: [point],
|
||||
color: selectedColor,
|
||||
width: brushWidth,
|
||||
color: style.color,
|
||||
width: style.width,
|
||||
timestamp: Date.now(),
|
||||
isEraser,
|
||||
isEraser: style.isEraser,
|
||||
};
|
||||
currentStrokeIdRef.current = stroke.id;
|
||||
canvasRef.current?.startStroke(stroke);
|
||||
sync.broadcastStrokeStart(stroke);
|
||||
currentStrokeRef.current = stroke;
|
||||
sync.beginLocalStroke(stroke);
|
||||
},
|
||||
[user?.id, selectedColor, brushWidth, isEraser, strokes.length, sync]
|
||||
[sync]
|
||||
);
|
||||
|
||||
const handleTouchMove = useCallback(
|
||||
(point: StrokePoint) => {
|
||||
if (!currentStrokeIdRef.current) return;
|
||||
canvasRef.current?.addPoint(point);
|
||||
sync.broadcastStrokeContinue(currentStrokeIdRef.current, [point]);
|
||||
const stroke = currentStrokeRef.current;
|
||||
if (!stroke) return;
|
||||
stroke.points.push(point);
|
||||
sync.appendLocalPoint(point);
|
||||
},
|
||||
[sync]
|
||||
);
|
||||
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
if (!currentStrokeIdRef.current) return;
|
||||
const finished = canvasRef.current?.endStroke() ?? null;
|
||||
currentStrokeIdRef.current = null;
|
||||
if (finished) {
|
||||
addStroke(finished);
|
||||
sync.broadcastStrokeEnd(finished.id);
|
||||
sync.persistStrokes([...strokes, finished]);
|
||||
}
|
||||
}, [addStroke, strokes, sync]);
|
||||
const finished = currentStrokeRef.current;
|
||||
currentStrokeRef.current = null;
|
||||
if (!finished) return;
|
||||
addStroke(finished);
|
||||
void sync.endLocalStroke(finished);
|
||||
// Drop the UI-thread copy only after React has painted the committed
|
||||
// stroke, otherwise the line blinks out for a frame.
|
||||
requestAnimationFrame(() => canvasRef.current?.clearLiveStroke());
|
||||
}, [addStroke, sync]);
|
||||
|
||||
const handleUndo = useCallback(() => {
|
||||
if (!user?.id) return;
|
||||
const removed = undo(user.id);
|
||||
if (removed) {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
canvasRef.current?.clearLiveStroke();
|
||||
sync.broadcastUndo(removed.id);
|
||||
sync.persistStrokes(strokes.filter((s) => s.id !== removed.id));
|
||||
void sync.persistStrokes(strokesRef.current.filter((s) => s.id !== removed.id));
|
||||
}
|
||||
}, [user?.id, undo, strokes, sync]);
|
||||
}, [user?.id, undo, sync]);
|
||||
|
||||
const handleRedo = useCallback(() => {
|
||||
if (!user?.id) return;
|
||||
@@ -153,9 +173,9 @@ export const LiveDrawScreen: React.FC = () => {
|
||||
if (restored) {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
sync.broadcastRedo(restored);
|
||||
sync.persistStrokes([...strokes, restored]);
|
||||
void sync.persistStrokes([...strokesRef.current, restored]);
|
||||
}
|
||||
}, [user?.id, redo, strokes, sync]);
|
||||
}, [user?.id, redo, sync]);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
Alert.alert('Clear Canvas', 'This will erase the entire drawing.', [
|
||||
@@ -165,8 +185,9 @@ export const LiveDrawScreen: React.FC = () => {
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
clearAll();
|
||||
canvasRef.current?.clearLiveStroke();
|
||||
sync.broadcastClearCanvas();
|
||||
sync.persistStrokes([]);
|
||||
void sync.persistStrokes([]);
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -203,6 +224,9 @@ export const LiveDrawScreen: React.FC = () => {
|
||||
strokes={strokes}
|
||||
canvasWidth={canvasWidth}
|
||||
canvasHeight={canvasHeight}
|
||||
strokeColor={selectedColor}
|
||||
strokeWidth={brushWidth}
|
||||
isEraser={isEraser}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
|
||||
@@ -1,26 +1,28 @@
|
||||
import React, { useMemo, useRef, useEffect, useCallback, useState, useImperativeHandle, forwardRef } from 'react';
|
||||
import React, { useMemo, useRef, useEffect, useImperativeHandle, forwardRef } from 'react';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import {
|
||||
Canvas,
|
||||
Path,
|
||||
Skia,
|
||||
} from '@shopify/react-native-skia';
|
||||
import type { SkImage } from '@shopify/react-native-skia';
|
||||
import type { SkImage, SkPath } from '@shopify/react-native-skia';
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
||||
import { runOnJS } from 'react-native-reanimated';
|
||||
import { runOnJS, useDerivedValue, useSharedValue } from 'react-native-reanimated';
|
||||
import type { StrokeSegment, StrokePoint } from '../types';
|
||||
import { CANVAS_BACKGROUND } from '../constants';
|
||||
|
||||
export interface DrawingCanvasHandle {
|
||||
startStroke: (stroke: StrokeSegment) => void;
|
||||
addPoint: (point: StrokePoint) => void;
|
||||
endStroke: () => StrokeSegment | null;
|
||||
/** Drops the in-progress stroke rendered on the UI thread. */
|
||||
clearLiveStroke: () => void;
|
||||
}
|
||||
|
||||
interface DrawingCanvasProps {
|
||||
strokes: StrokeSegment[];
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
strokeColor: string;
|
||||
strokeWidth: number;
|
||||
isEraser: boolean;
|
||||
onTouchStart: (point: StrokePoint) => void;
|
||||
onTouchMove: (point: StrokePoint) => void;
|
||||
onTouchEnd: () => void;
|
||||
@@ -85,41 +87,15 @@ const StrokePath = React.memo(function StrokePath({
|
||||
);
|
||||
});
|
||||
|
||||
// Renders the in-progress stroke from a ref, re-rendered only via rafTick
|
||||
const CurrentStrokePath = React.memo(function CurrentStrokePath({
|
||||
strokeRef,
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
_tick,
|
||||
}: {
|
||||
strokeRef: React.RefObject<StrokeSegment | null>;
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
_tick: number; // forces re-render at RAF cadence
|
||||
}) {
|
||||
const stroke = strokeRef.current;
|
||||
if (!stroke) return null;
|
||||
|
||||
const path = buildPath(stroke.points, canvasWidth, canvasHeight);
|
||||
|
||||
return (
|
||||
<Path
|
||||
path={path}
|
||||
color={stroke.isEraser ? CANVAS_BACKGROUND : stroke.color}
|
||||
style="stroke"
|
||||
strokeWidth={stroke.width}
|
||||
strokeCap="round"
|
||||
strokeJoin="round"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const DrawingCanvas = forwardRef<DrawingCanvasHandle, DrawingCanvasProps>(
|
||||
function DrawingCanvas(
|
||||
{
|
||||
strokes,
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
strokeColor,
|
||||
strokeWidth,
|
||||
isEraser,
|
||||
onTouchStart,
|
||||
onTouchMove,
|
||||
onTouchEnd,
|
||||
@@ -128,9 +104,23 @@ export const DrawingCanvas = forwardRef<DrawingCanvasHandle, DrawingCanvasProps>
|
||||
ref
|
||||
) {
|
||||
const canvasRef = useRef<any>(null);
|
||||
const currentStrokeRef = useRef<StrokeSegment | null>(null);
|
||||
const rafScheduled = useRef(false);
|
||||
const [rafTick, setRafTick] = useState(0);
|
||||
|
||||
// The in-progress stroke lives entirely on the UI thread: the pan gesture
|
||||
// extends the path in place, so ink never waits on the JS thread.
|
||||
const livePath = useSharedValue<SkPath | null>(null);
|
||||
const livePrev = useSharedValue<StrokePoint | null>(null);
|
||||
// Doubles as the invalidation signal: the path is mutated in place, so this
|
||||
// counter is what tells the derived value to re-read it.
|
||||
const livePointCount = useSharedValue(0);
|
||||
|
||||
const liveColor = isEraser ? CANVAS_BACKGROUND : strokeColor;
|
||||
|
||||
// Re-derived once per frame that the path changed — not once per point.
|
||||
const renderedLivePath = useDerivedValue(() => {
|
||||
const count = livePointCount.value;
|
||||
const path = livePath.value;
|
||||
return count > 0 && path ? path.copy() : Skia.Path.Make();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (onCanvasReady) {
|
||||
@@ -140,52 +130,67 @@ export const DrawingCanvas = forwardRef<DrawingCanvasHandle, DrawingCanvasProps>
|
||||
}
|
||||
}, [onCanvasReady]);
|
||||
|
||||
const scheduleRafUpdate = useCallback(() => {
|
||||
if (rafScheduled.current) return;
|
||||
rafScheduled.current = true;
|
||||
requestAnimationFrame(() => {
|
||||
rafScheduled.current = false;
|
||||
setRafTick((t) => t + 1);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
startStroke(stroke: StrokeSegment) {
|
||||
currentStrokeRef.current = stroke;
|
||||
scheduleRafUpdate();
|
||||
clearLiveStroke() {
|
||||
livePath.value = null;
|
||||
livePrev.value = null;
|
||||
livePointCount.value = 0;
|
||||
},
|
||||
addPoint(point: StrokePoint) {
|
||||
const s = currentStrokeRef.current;
|
||||
if (!s) return;
|
||||
// Mutate in place — no React state involved
|
||||
s.points.push(point);
|
||||
scheduleRafUpdate();
|
||||
},
|
||||
endStroke(): StrokeSegment | null {
|
||||
const s = currentStrokeRef.current;
|
||||
currentStrokeRef.current = null;
|
||||
scheduleRafUpdate();
|
||||
return s;
|
||||
},
|
||||
}), [scheduleRafUpdate]);
|
||||
}), [livePath, livePrev, livePointCount]);
|
||||
|
||||
const pan = Gesture.Pan()
|
||||
.minDistance(0)
|
||||
.onStart((e) => {
|
||||
runOnJS(onTouchStart)({
|
||||
x: e.x / canvasWidth,
|
||||
y: e.y / canvasHeight,
|
||||
});
|
||||
})
|
||||
.onUpdate((e) => {
|
||||
runOnJS(onTouchMove)({
|
||||
x: e.x / canvasWidth,
|
||||
y: e.y / canvasHeight,
|
||||
});
|
||||
})
|
||||
.onEnd(() => {
|
||||
runOnJS(onTouchEnd)();
|
||||
});
|
||||
const pan = useMemo(
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
.minDistance(0)
|
||||
.onStart((e) => {
|
||||
'worklet';
|
||||
const path = Skia.Path.Make();
|
||||
path.moveTo(e.x, e.y);
|
||||
livePath.value = path;
|
||||
livePrev.value = { x: e.x, y: e.y };
|
||||
livePointCount.value = 1;
|
||||
runOnJS(onTouchStart)({ x: e.x / canvasWidth, y: e.y / canvasHeight });
|
||||
})
|
||||
.onUpdate((e) => {
|
||||
'worklet';
|
||||
const path = livePath.value;
|
||||
const prev = livePrev.value;
|
||||
if (path && prev) {
|
||||
// Same quadratic smoothing as buildPath, applied incrementally.
|
||||
const midX = (prev.x + e.x) / 2;
|
||||
const midY = (prev.y + e.y) / 2;
|
||||
if (livePointCount.value === 1) {
|
||||
path.lineTo(midX, midY);
|
||||
} else {
|
||||
path.quadTo(prev.x, prev.y, midX, midY);
|
||||
}
|
||||
livePrev.value = { x: e.x, y: e.y };
|
||||
livePointCount.value += 1;
|
||||
}
|
||||
runOnJS(onTouchMove)({ x: e.x / canvasWidth, y: e.y / canvasHeight });
|
||||
})
|
||||
.onEnd(() => {
|
||||
'worklet';
|
||||
const path = livePath.value;
|
||||
const prev = livePrev.value;
|
||||
if (path && prev) {
|
||||
// Close the half-segment the incremental midpoints leave at the tip.
|
||||
path.lineTo(prev.x, prev.y);
|
||||
livePointCount.value += 1;
|
||||
}
|
||||
runOnJS(onTouchEnd)();
|
||||
}),
|
||||
[
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
onTouchStart,
|
||||
onTouchMove,
|
||||
onTouchEnd,
|
||||
livePath,
|
||||
livePrev,
|
||||
livePointCount,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={pan}>
|
||||
@@ -199,11 +204,13 @@ export const DrawingCanvas = forwardRef<DrawingCanvasHandle, DrawingCanvasProps>
|
||||
canvasHeight={canvasHeight}
|
||||
/>
|
||||
))}
|
||||
<CurrentStrokePath
|
||||
strokeRef={currentStrokeRef}
|
||||
canvasWidth={canvasWidth}
|
||||
canvasHeight={canvasHeight}
|
||||
_tick={rafTick}
|
||||
<Path
|
||||
path={renderedLivePath}
|
||||
color={liveColor}
|
||||
style="stroke"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeCap="round"
|
||||
strokeJoin="round"
|
||||
/>
|
||||
</Canvas>
|
||||
</View>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { ApiError } from '../../../lib/apiClient';
|
||||
import { appDataApi, type LiveDrawState } from '../../../lib/appDataApi';
|
||||
import type { StrokeSegment, StrokePoint } from '../types';
|
||||
@@ -17,9 +17,9 @@ interface UseDrawingSyncConfig {
|
||||
}
|
||||
|
||||
interface UseDrawingSyncReturn {
|
||||
broadcastStrokeStart: (stroke: StrokeSegment) => void;
|
||||
broadcastStrokeContinue: (strokeId: string, points: StrokePoint[]) => void;
|
||||
broadcastStrokeEnd: (strokeId: string) => void;
|
||||
beginLocalStroke: (stroke: StrokeSegment) => void;
|
||||
appendLocalPoint: (point: StrokePoint) => void;
|
||||
endLocalStroke: (stroke: StrokeSegment) => Promise<void>;
|
||||
broadcastClearCanvas: () => void;
|
||||
broadcastUndo: (strokeId: string) => void;
|
||||
broadcastRedo: (stroke: StrokeSegment) => void;
|
||||
@@ -32,6 +32,14 @@ function conflictState(error: unknown): LiveDrawState | null {
|
||||
return state && Array.isArray(state.strokes) && Number.isInteger(state.revision) ? state : null;
|
||||
}
|
||||
|
||||
// Keeps the position of the first occurrence (stable z-order) but the value of
|
||||
// the last (the most complete copy of a stroke that was persisted mid-draw).
|
||||
export function dedupeById(strokes: StrokeSegment[]): StrokeSegment[] {
|
||||
const byId = new Map<string, StrokeSegment>();
|
||||
for (const stroke of strokes) byId.set(stroke.id, stroke);
|
||||
return [...byId.values()];
|
||||
}
|
||||
|
||||
export function mergeDrawingChanges(base: StrokeSegment[], desired: StrokeSegment[], current: StrokeSegment[]): StrokeSegment[] {
|
||||
const desiredById = new Map(desired.map(stroke => [stroke.id, stroke]));
|
||||
const desiredIds = new Set(desiredById.keys());
|
||||
@@ -40,7 +48,7 @@ export function mergeDrawingChanges(base: StrokeSegment[], desired: StrokeSegmen
|
||||
const merged = current
|
||||
.filter(stroke => !removedIds.has(stroke.id))
|
||||
.map(stroke => desiredById.get(stroke.id) ?? stroke);
|
||||
return [...merged, ...desired.filter(stroke => !currentIds.has(stroke.id))];
|
||||
return dedupeById([...merged, ...desired.filter(stroke => !currentIds.has(stroke.id))]);
|
||||
}
|
||||
|
||||
export function useDrawingSync(config: UseDrawingSyncConfig): UseDrawingSyncReturn {
|
||||
@@ -50,13 +58,38 @@ export function useDrawingSync(config: UseDrawingSyncConfig): UseDrawingSyncRetu
|
||||
const workingRef = useRef<StrokeSegment[]>([]);
|
||||
const writeQueueRef = useRef<Promise<void>>(Promise.resolve());
|
||||
const pendingWritesRef = useRef(0);
|
||||
const throttleRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const throttleRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// The stroke currently under the finger. Owned here and mutated in place so a
|
||||
// touch move costs a push, not a rebuild of every stroke on the canvas.
|
||||
const localStrokeRef = useRef<StrokeSegment | null>(null);
|
||||
const onLoadStrokesRef = useRef(onLoadStrokes);
|
||||
onLoadStrokesRef.current = onLoadStrokes;
|
||||
|
||||
// workingRef never holds the in-progress stroke; compose it in only when we
|
||||
// are about to send, so the O(n) copy happens once per write, not per point.
|
||||
const composeWorking = useCallback((): StrokeSegment[] => {
|
||||
const local = localStrokeRef.current;
|
||||
if (!local) return workingRef.current;
|
||||
return dedupeById([
|
||||
...workingRef.current,
|
||||
{ ...local, points: [...local.points] },
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const acceptState = useCallback((state: LiveDrawState) => {
|
||||
revisionRef.current = state.revision;
|
||||
baselineRef.current = state.strokes;
|
||||
|
||||
const local = localStrokeRef.current;
|
||||
if (local) {
|
||||
// Mid-stroke: adopt the remote strokes for the next write, but do NOT
|
||||
// push them into React state. The server's copy of the stroke under the
|
||||
// finger is always behind it, and handing it to setStrokes is what makes
|
||||
// the line snap backwards while drawing.
|
||||
workingRef.current = state.strokes.filter(stroke => stroke.id !== local.id);
|
||||
return;
|
||||
}
|
||||
|
||||
workingRef.current = state.strokes;
|
||||
onLoadStrokesRef.current(state.strokes);
|
||||
}, []);
|
||||
@@ -71,26 +104,27 @@ export function useDrawingSync(config: UseDrawingSyncConfig): UseDrawingSyncRetu
|
||||
const merged = mergeDrawingChanges(base, desired, current.strokes);
|
||||
acceptState(await appDataApi.putLiveDraw(merged, current.revision));
|
||||
}
|
||||
updateWidget(workingRef.current);
|
||||
}, [acceptState]);
|
||||
updateWidget(composeWorking());
|
||||
}, [acceptState, composeWorking]);
|
||||
|
||||
const enqueueWrite = useCallback((strokes: StrokeSegment[]): Promise<void> => {
|
||||
workingRef.current = strokes;
|
||||
const desired = dedupeById(strokes);
|
||||
workingRef.current = desired.filter(stroke => stroke.id !== localStrokeRef.current?.id);
|
||||
pendingWritesRef.current += 1;
|
||||
const next = writeQueueRef.current.then(() => writeSnapshot(strokes));
|
||||
const next = writeQueueRef.current.then(() => writeSnapshot(desired));
|
||||
writeQueueRef.current = next.catch(error => { console.error('Failed to persist live drawing:', error); });
|
||||
return next.finally(() => { pendingWritesRef.current -= 1; });
|
||||
}, [writeSnapshot]);
|
||||
|
||||
const scheduleWrite = useCallback((strokes: StrokeSegment[]) => {
|
||||
workingRef.current = strokes;
|
||||
if (!throttleRef.current) {
|
||||
throttleRef.current = setTimeout(() => {
|
||||
throttleRef.current = null;
|
||||
void enqueueWrite(workingRef.current);
|
||||
}, 250);
|
||||
}
|
||||
}, [enqueueWrite]);
|
||||
// Throttled: reads the latest state at fire time rather than capturing a
|
||||
// freshly built array on every call.
|
||||
const scheduleWrite = useCallback(() => {
|
||||
if (throttleRef.current) return;
|
||||
throttleRef.current = setTimeout(() => {
|
||||
throttleRef.current = null;
|
||||
void enqueueWrite(composeWorking());
|
||||
}, 250);
|
||||
}, [enqueueWrite, composeWorking]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!coupleId) return;
|
||||
@@ -112,21 +146,45 @@ export function useDrawingSync(config: UseDrawingSyncConfig): UseDrawingSyncRetu
|
||||
return () => { cancelled = true; clearInterval(timer); if (throttleRef.current) clearTimeout(throttleRef.current); };
|
||||
}, [coupleId, userId, acceptState]);
|
||||
|
||||
const broadcastStrokeStart = useCallback((stroke: StrokeSegment) => {
|
||||
scheduleWrite([...workingRef.current.filter(item => item.id !== stroke.id), stroke]);
|
||||
}, [scheduleWrite]);
|
||||
const broadcastStrokeContinue = useCallback((strokeId: string, points: StrokePoint[]) => {
|
||||
scheduleWrite(workingRef.current.map(stroke => stroke.id === strokeId ? { ...stroke, points: [...stroke.points, ...points] } : stroke));
|
||||
}, [scheduleWrite]);
|
||||
const broadcastStrokeEnd = useCallback((_strokeId: string) => undefined, []);
|
||||
const broadcastClearCanvas = useCallback(() => { workingRef.current = []; }, []);
|
||||
const broadcastUndo = useCallback((strokeId: string) => { workingRef.current = workingRef.current.filter(stroke => stroke.id !== strokeId); }, []);
|
||||
const broadcastRedo = useCallback((stroke: StrokeSegment) => { workingRef.current = [...workingRef.current.filter(item => item.id !== stroke.id), stroke]; }, []);
|
||||
const persistStrokes = useCallback((strokes: StrokeSegment[]) => {
|
||||
if (throttleRef.current) clearTimeout(throttleRef.current);
|
||||
throttleRef.current = null;
|
||||
return enqueueWrite(strokes);
|
||||
}, [enqueueWrite]);
|
||||
|
||||
return { broadcastStrokeStart, broadcastStrokeContinue, broadcastStrokeEnd, broadcastClearCanvas, broadcastUndo, broadcastRedo, persistStrokes };
|
||||
const beginLocalStroke = useCallback((stroke: StrokeSegment) => {
|
||||
localStrokeRef.current = { ...stroke, points: [...stroke.points] };
|
||||
workingRef.current = workingRef.current.filter(item => item.id !== stroke.id);
|
||||
scheduleWrite();
|
||||
}, [scheduleWrite]);
|
||||
|
||||
const appendLocalPoint = useCallback((point: StrokePoint) => {
|
||||
localStrokeRef.current?.points.push(point);
|
||||
scheduleWrite();
|
||||
}, [scheduleWrite]);
|
||||
|
||||
const endLocalStroke = useCallback((stroke: StrokeSegment) => {
|
||||
localStrokeRef.current = null;
|
||||
// dedupeById drops the truncated copy the mid-draw writes left behind.
|
||||
return persistStrokes([...workingRef.current, stroke]);
|
||||
}, [persistStrokes]);
|
||||
|
||||
const broadcastClearCanvas = useCallback(() => {
|
||||
localStrokeRef.current = null;
|
||||
workingRef.current = [];
|
||||
}, []);
|
||||
const broadcastUndo = useCallback((strokeId: string) => { workingRef.current = workingRef.current.filter(stroke => stroke.id !== strokeId); }, []);
|
||||
const broadcastRedo = useCallback((stroke: StrokeSegment) => { workingRef.current = dedupeById([...workingRef.current, stroke]); }, []);
|
||||
|
||||
// Stable identity: the screen's touch handlers depend on this object, and a
|
||||
// new one every render rebuilt the pan gesture mid-stroke.
|
||||
return useMemo(() => ({
|
||||
beginLocalStroke,
|
||||
appendLocalPoint,
|
||||
endLocalStroke,
|
||||
broadcastClearCanvas,
|
||||
broadcastUndo,
|
||||
broadcastRedo,
|
||||
persistStrokes,
|
||||
}), [beginLocalStroke, appendLocalPoint, endLocalStroke, broadcastClearCanvas, broadcastUndo, broadcastRedo, persistStrokes]);
|
||||
}
|
||||
@@ -37,6 +37,7 @@ export const NotificationSettings: React.FC<NotificationSettingsProps> = ({
|
||||
onPackChangesToggle: (value: boolean) => updatePreference('pack_changes_enabled', value),
|
||||
onNewPacksToggle: (value: boolean) => updatePreference('new_packs_enabled', value),
|
||||
onStreakMilestonesToggle: (value: boolean) => updatePreference('streak_milestones_enabled', value),
|
||||
onStreakRemindersToggle: (value: boolean) => updatePreference('streak_reminders_enabled', value),
|
||||
onWeeklySummaryToggle: (value: boolean) => updatePreference('weekly_summary_enabled', value),
|
||||
onUnpairedRemindersToggle: (value: boolean) => updatePreference('unpaired_reminders_enabled', value),
|
||||
onCatchupRemindersToggle: (value: boolean) => updatePreference('catchup_reminders_enabled', value),
|
||||
@@ -138,6 +139,21 @@ export const NotificationSettings: React.FC<NotificationSettingsProps> = ({
|
||||
|
||||
<View style={styles.itemDivider} />
|
||||
|
||||
{partner && (
|
||||
<>
|
||||
<SwitchItem
|
||||
icon="alarm-outline"
|
||||
label="Streak Reminders"
|
||||
description="An evening nudge while your streak is still open"
|
||||
value={preferences.streak_reminders_enabled}
|
||||
onValueChange={handlers.onStreakRemindersToggle}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
|
||||
<View style={styles.itemDivider} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<SwitchItem
|
||||
icon="calendar-outline"
|
||||
label="Weekly Recap"
|
||||
|
||||
@@ -115,6 +115,7 @@ const SwipeScreen = () => {
|
||||
packContext={packContext}
|
||||
showBackButton={!!packId || mode === 'pending'}
|
||||
onBack={() => router.back()}
|
||||
dailyLimitInfo={dailyLimitInfo}
|
||||
/>
|
||||
|
||||
<SwipeCardStack
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import Animated, { FadeIn, FadeInUp } from "react-native-reanimated";
|
||||
@@ -6,6 +7,7 @@ import { LinearGradient } from "expo-linear-gradient";
|
||||
import { DecorativeSeparator, GradientBackground } from "../../../components/ui";
|
||||
import { Paywall } from "../../../components/paywall";
|
||||
import { colors, radius, spacing, typography } from "../../../theme";
|
||||
import { Events } from "../../../lib/analytics";
|
||||
import type { DailyLimitInfo } from "../types";
|
||||
|
||||
interface SwipeDailyLimitStateProps {
|
||||
@@ -27,6 +29,11 @@ export const SwipeDailyLimitState = ({
|
||||
}: SwipeDailyLimitStateProps) => {
|
||||
const accent = colors.premium.gold;
|
||||
|
||||
// The meter exists to convert; without these the funnel is unmeasurable.
|
||||
useEffect(() => {
|
||||
Events.paywallShown('daily_limit');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<GradientBackground>
|
||||
<View style={styles.centerContainer}>
|
||||
@@ -59,7 +66,7 @@ export const SwipeDailyLimitState = ({
|
||||
</Animated.View>
|
||||
|
||||
<Text style={styles.dailyLimitDescription}>
|
||||
You've answered {dailyLimitInfo.limit_value} questions today!{'\n'}
|
||||
You've answered {dailyLimitInfo.responses_today} questions today!{'\n'}
|
||||
Let the anticipation build while you wait.
|
||||
</Text>
|
||||
|
||||
@@ -100,8 +107,14 @@ export const SwipeDailyLimitState = ({
|
||||
|
||||
<Paywall
|
||||
visible={showPaywall}
|
||||
onClose={onClosePaywall}
|
||||
onSuccess={onPaywallSuccess}
|
||||
onClose={() => {
|
||||
Events.paywallClosed('daily_limit', 'dismissed');
|
||||
onClosePaywall();
|
||||
}}
|
||||
onSuccess={() => {
|
||||
Events.paywallClosed('daily_limit', 'purchased');
|
||||
onPaywallSuccess();
|
||||
}}
|
||||
/>
|
||||
</GradientBackground>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,12 @@ import Animated, { FadeIn } from "react-native-reanimated";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
|
||||
import { colors, spacing, typography } from "../../../theme";
|
||||
import { StreakDisplay } from "../../../components/StreakDisplay";
|
||||
import { useProgressShimmer } from "../hooks/useProgressShimmer";
|
||||
import type { DailyLimitInfo } from "../types";
|
||||
|
||||
/** Remaining answers at or below this switch the meter to its "running out" treatment. */
|
||||
const SCARCITY_THRESHOLD = 3;
|
||||
|
||||
interface SwipeHeaderProps {
|
||||
currentIndex: number;
|
||||
@@ -14,6 +19,7 @@ interface SwipeHeaderProps {
|
||||
packContext: { name: string } | null;
|
||||
showBackButton: boolean;
|
||||
onBack: () => void;
|
||||
dailyLimitInfo?: DailyLimitInfo | null;
|
||||
}
|
||||
|
||||
export const SwipeHeader = ({
|
||||
@@ -24,11 +30,20 @@ export const SwipeHeader = ({
|
||||
packContext,
|
||||
showBackButton,
|
||||
onBack,
|
||||
dailyLimitInfo,
|
||||
}: SwipeHeaderProps) => {
|
||||
const shimmerStyle = useProgressShimmer();
|
||||
const total = effectiveTotal || totalQuestions;
|
||||
const progressPercent = total > 0 ? ((currentIndex + 1) / total) * 100 : 0;
|
||||
|
||||
// Catch-up answers are exempt from the cap, so the meter is hidden in pending mode.
|
||||
const showMeter = mode !== 'pending'
|
||||
&& !!dailyLimitInfo
|
||||
&& dailyLimitInfo.limit_value > 0
|
||||
&& !dailyLimitInfo.is_blocked;
|
||||
const remaining = dailyLimitInfo?.remaining ?? 0;
|
||||
const isRunningOut = showMeter && remaining <= SCARCITY_THRESHOLD;
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(400)}
|
||||
@@ -47,6 +62,16 @@ export const SwipeHeader = ({
|
||||
<Text style={styles.progressText}>
|
||||
{currentIndex + 1} of {total}
|
||||
</Text>
|
||||
{showMeter && (
|
||||
<View style={[styles.meter, isRunningOut && styles.meterUrgent]}>
|
||||
{isRunningOut && (
|
||||
<Ionicons name="hourglass-outline" size={12} color={colors.premium.gold} />
|
||||
)}
|
||||
<Text style={[styles.meterText, isRunningOut && styles.meterTextUrgent]}>
|
||||
{remaining === 1 ? '1 left today' : `${remaining} left today`}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={[styles.progressBar, styles.progressBarPremium]}>
|
||||
<Animated.View
|
||||
style={[
|
||||
@@ -66,6 +91,11 @@ export const SwipeHeader = ({
|
||||
</Animated.View>
|
||||
</View>
|
||||
</View>
|
||||
{/* The shared streak sits opposite the back button so the centred
|
||||
progress column keeps its position. */}
|
||||
<View style={styles.streakBadge} pointerEvents="none">
|
||||
<StreakDisplay compact delay={200} />
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
@@ -87,6 +117,10 @@ const styles = StyleSheet.create({
|
||||
left: 0,
|
||||
padding: spacing.sm,
|
||||
},
|
||||
streakBadge: {
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
},
|
||||
progressContainer: {
|
||||
alignItems: "center",
|
||||
},
|
||||
@@ -100,8 +134,31 @@ const styles = StyleSheet.create({
|
||||
progressText: {
|
||||
...typography.subhead,
|
||||
color: colors.textSecondary,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
meter: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
meterUrgent: {
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 999,
|
||||
backgroundColor: 'rgba(212, 175, 55, 0.1)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(212, 175, 55, 0.2)',
|
||||
},
|
||||
meterText: {
|
||||
...typography.caption2,
|
||||
letterSpacing: 0.5,
|
||||
color: colors.textTertiary,
|
||||
},
|
||||
meterTextUrgent: {
|
||||
color: colors.premium.gold,
|
||||
fontWeight: '600',
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
progressBar: {
|
||||
width: 160,
|
||||
height: 6,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
||||
|
||||
import { useAuthStore, usePacksStore } from "../../../store";
|
||||
import { useAuthStore, usePacksStore, useStreakStore } from "../../../store";
|
||||
import { skipQuestion, getSkippedQuestionIds } from "../../../lib/skippedQuestions";
|
||||
import { apiClient } from "../../../lib/apiClient";
|
||||
import { ApiError, apiClient } from "../../../lib/apiClient";
|
||||
import { Events } from "../../../lib/analytics";
|
||||
import type { AnswerType } from "../../../types";
|
||||
import type { DailyLimitInfo, PackInfo, ResponseData } from "../types";
|
||||
@@ -128,8 +128,16 @@ export const useSwipeScreen = () => {
|
||||
filtered = data;
|
||||
}
|
||||
|
||||
if (partner && !packId) {
|
||||
await Promise.all([checkAnswerGap(), checkDailyLimit()]);
|
||||
if (partner) {
|
||||
// The daily limit applies to pack browsing too, so it is read in both
|
||||
// modes; the answer gap is deliberately only enforced outside packs.
|
||||
if (packId) {
|
||||
setIsBlocked(false);
|
||||
setGapInfo(null);
|
||||
await checkDailyLimit();
|
||||
} else {
|
||||
await Promise.all([checkAnswerGap(), checkDailyLimit()]);
|
||||
}
|
||||
} else {
|
||||
setIsBlocked(false);
|
||||
setGapInfo(null);
|
||||
@@ -334,8 +342,40 @@ export const useSwipeScreen = () => {
|
||||
}
|
||||
|
||||
await Promise.all([checkAnswerGap(), checkDailyLimit()]);
|
||||
} else {
|
||||
// Catch-up answers are exempt from the cap, but the partner may have
|
||||
// answered after this screen loaded, so re-read rather than assume.
|
||||
await checkDailyLimit();
|
||||
}
|
||||
|
||||
// The shared streak turns on today's answers, so a stale badge would show the
|
||||
// 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);
|
||||
} 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.
|
||||
if (error instanceof ApiError && error.status === 429) {
|
||||
// ApiError.details carries the whole response body: { error, details }.
|
||||
const body = (error.details ?? {}) as {
|
||||
details?: {
|
||||
daily_limit?: number;
|
||||
responses_today?: number;
|
||||
remaining?: number;
|
||||
reset_at?: string;
|
||||
};
|
||||
};
|
||||
const details = body.details ?? {};
|
||||
setDailyLimitInfo(prev => ({
|
||||
responses_today: details.responses_today ?? prev?.responses_today ?? 0,
|
||||
limit_value: details.daily_limit ?? prev?.limit_value ?? 0,
|
||||
remaining: details.remaining ?? 0,
|
||||
reset_at: details.reset_at ?? prev?.reset_at ?? '',
|
||||
is_blocked: true,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("Failed to submit response", error);
|
||||
setCurrentIndex(prev => prev + 1);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user