chahinebrini 89e4e3481b feat(calls): Phase 0 — calls_enabled opt-out + canCall guard (mutual-follow); DM UI batch
Backend (voice-call groundwork, no call engine yet):
- Profile.callsEnabled (Boolean default true) + migration
- canCall(caller,callee): mutual-follow AND callee.callsEnabled — server-side hard guard
- POST /api/me/calls-enabled (opt-out toggle), GET /api/chat/can-call/:userId
- expose callsEnabled in /api/auth/me

Frontend:
- "Allow calls" toggle in Profile privacy section (default on, optimistic+rollback)
- Me.callsEnabled + i18n DE/EN/FR/AR

Bundled DM UI work from this session:
- image lightbox is now a swipeable carousel over all shared images (+ counter)
- keyboard stays open after sending (input ref refocus)
- voice notes: Instagram-style waveforms (own=white/mint, other=black/grey),
  removed the blue progress dot; lazy-load expo-media-library with clean fallback
- expo-linear-gradient + expo-media-library deps

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 21:14:31 +02:00

104 lines
2.8 KiB
TypeScript

import { useEffect, useState } from 'react';
import { apiFetch } from '../lib/api';
export type Plan = 'free' | 'pro' | 'legend';
/**
* Single source of truth für den eingeloggten User. /api/auth/me joint
* `auth.users` mit `rebreak.profiles` server-side — wir bekommen alles in
* einem Request: plan, avatar, nickname, streak.
*
* Live-Update-Pattern (siehe RECOVERY_LOG): nach Profile-Edit (PATCH /api/auth/me)
* MUSS `invalidateMe()` aufgerufen werden — alle useMe-Konsumenten (AppHeader,
* PostCard, ComposeCard, etc.) re-fetchen automatisch via Listener-Subscribe.
*
* WICHTIG: nicht aus `supabase.auth.getUser().user_metadata` lesen — das
* sind nur die JWT-Claims vom Signup-Zeitpunkt, NICHT der aktuelle Profile-
* Stand (Avatar/Nickname/Plan werden via Profile-Edit-API geupdated, landen
* in der DB, NICHT zurück ins JWT-Claim).
*/
export type OnboardingStep =
| 'welcome'
| 'account'
| 'plan'
| 'pre_protection'
| 'done'
// legacy (alte Builds könnten das im Profile haben — wird im neuen Flow nicht gesetzt)
| 'nickname'
| 'block';
export type Me = {
id: string;
email: string;
username: string;
nickname: string | null;
avatar: string | null;
plan: Plan;
streak: number;
lyraVoiceId: string | null;
onboardingStep: OnboardingStep;
created_at?: string;
presenceVisible?: boolean;
callsEnabled?: boolean;
};
let cachedMe: Me | null = null;
const listeners = new Set<() => void>();
/**
* Lädt /api/auth/me neu und benachrichtigt ALLE useMe-Konsumenten in der App.
* Nach jedem PATCH /api/auth/me aufrufen — sonst sehen Konsumenten alten Cache.
*/
export function invalidateMe(): void {
cachedMe = null;
for (const cb of listeners) cb();
}
export function useMe(): { me: Me | null; loading: boolean; reload: () => void } {
const [me, setMe] = useState<Me | null>(cachedMe);
const [loading, setLoading] = useState(cachedMe === null);
const [version, setVersion] = useState(0);
// Auf globale Invalidierung lauschen (Avatar-/Nickname-Update aus Profile-Edit)
useEffect(() => {
const cb = () => setVersion((v) => v + 1);
listeners.add(cb);
return () => {
listeners.delete(cb);
};
}, []);
useEffect(() => {
let cancelled = false;
(async () => {
// Falls cache schon frisch ist (von anderem Konsumenten gerade geladen): nutzen
if (cachedMe !== null) {
setMe(cachedMe);
setLoading(false);
return;
}
try {
const res = await apiFetch<Me>('/api/auth/me');
if (cancelled) return;
cachedMe = res;
setMe(res);
} catch (e) {
console.warn('[useMe] fetch failed:', e);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [version]);
return {
me,
loading,
reload: () => {
invalidateMe();
},
};
}