initial
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
import {
|
||||
type DeviceRecord,
|
||||
getOwnProfile,
|
||||
signOut as supabaseSignOut,
|
||||
type Profile,
|
||||
} from '@chat-app/shared/auth';
|
||||
import { changeLocale, isSupportedLocale } from '@chat-app/shared/i18n';
|
||||
import type { Session } from '@supabase/supabase-js';
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { findExistingDevice } from '../lib/device';
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
interface AuthContextValue {
|
||||
session: Session | null;
|
||||
profile: Profile | null;
|
||||
device: DeviceRecord | null;
|
||||
// null while we're still resolving the very first auth state.
|
||||
ready: boolean;
|
||||
// null until a device lookup has finished for the current session.
|
||||
deviceLookupDone: boolean;
|
||||
refreshProfile: () => Promise<void>;
|
||||
refreshDevice: () => Promise<void>;
|
||||
setDevice: (device: DeviceRecord | null) => void;
|
||||
signOut: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const { i18n } = useTranslation();
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [profile, setProfile] = useState<Profile | null>(null);
|
||||
const [device, setDevice] = useState<DeviceRecord | null>(null);
|
||||
const [deviceLookupDone, setDeviceLookupDone] = useState(false);
|
||||
|
||||
// Initial session + auth subscription. We verify the cached JWT against the
|
||||
// server (via getUser) once on mount. Only purge the session on an
|
||||
// unambiguous 401/403 — a network failure (Supabase stack offline) must not
|
||||
// log the user out, otherwise every local `supabase stop` wipes their session.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const { data: sessionRes } = await supabase.auth.getSession();
|
||||
if (cancelled) return;
|
||||
if (sessionRes.session) {
|
||||
const { error } = await supabase.auth.getUser();
|
||||
if (cancelled) return;
|
||||
if (error) {
|
||||
const status = (error as { status?: number }).status;
|
||||
if (status === 401 || status === 403) {
|
||||
// Token genuinely invalid — wipe.
|
||||
await supabase.auth.signOut().catch(() => {
|
||||
/* ignore */
|
||||
});
|
||||
setSession(null);
|
||||
} else {
|
||||
// Network / server unreachable — keep cached session, let reads
|
||||
// fail gracefully and recover when the stack is back.
|
||||
console.warn('auth.getUser failed, keeping cached session:', error);
|
||||
setSession(sessionRes.session);
|
||||
}
|
||||
} else {
|
||||
setSession(sessionRes.session);
|
||||
}
|
||||
} else {
|
||||
setSession(null);
|
||||
}
|
||||
setReady(true);
|
||||
})();
|
||||
const { data: sub } = supabase.auth.onAuthStateChange((_event, s) => {
|
||||
setSession(s);
|
||||
setReady(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
sub.subscription.unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refreshProfile = useCallback(async () => {
|
||||
if (!session) {
|
||||
setProfile(null);
|
||||
return;
|
||||
}
|
||||
const p = await getOwnProfile(supabase);
|
||||
setProfile(p);
|
||||
if (p && p.locale !== i18n.resolvedLanguage && isSupportedLocale(p.locale)) {
|
||||
void changeLocale(p.locale);
|
||||
}
|
||||
}, [session, i18n.resolvedLanguage]);
|
||||
|
||||
const refreshDevice = useCallback(async () => {
|
||||
if (!session) {
|
||||
setDevice(null);
|
||||
setDeviceLookupDone(false);
|
||||
return;
|
||||
}
|
||||
setDeviceLookupDone(false);
|
||||
const found = await findExistingDevice(session.user.id);
|
||||
setDevice(found);
|
||||
setDeviceLookupDone(true);
|
||||
}, [session]);
|
||||
|
||||
// Re-pull profile + device whenever session flips.
|
||||
useEffect(() => {
|
||||
if (!session) {
|
||||
setProfile(null);
|
||||
setDevice(null);
|
||||
setDeviceLookupDone(false);
|
||||
return;
|
||||
}
|
||||
void refreshProfile().catch((err: unknown) => {
|
||||
console.error('refreshProfile failed', err);
|
||||
});
|
||||
void refreshDevice().catch((err: unknown) => {
|
||||
console.error('refreshDevice failed', err);
|
||||
setDeviceLookupDone(true);
|
||||
});
|
||||
}, [session, refreshProfile, refreshDevice]);
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
await supabaseSignOut(supabase);
|
||||
}, []);
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({
|
||||
session,
|
||||
profile,
|
||||
device,
|
||||
ready,
|
||||
deviceLookupDone,
|
||||
refreshProfile,
|
||||
refreshDevice,
|
||||
setDevice,
|
||||
signOut,
|
||||
}),
|
||||
[session, profile, device, ready, deviceLookupDone, refreshProfile, refreshDevice, signOut],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth must be used inside <AuthProvider>');
|
||||
return ctx;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,239 @@
|
||||
import { type ConversationSummary, listConversations } from '@chat-app/shared/chat';
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { playNotificationTone } from '../lib/notificationSound';
|
||||
import { notify } from '../lib/osNotify';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { useAuth } from './AuthContext';
|
||||
|
||||
const LAST_READ_STORAGE_KEY = 'chatapp.conv_last_read';
|
||||
const EPOCH = new Date(0).toISOString();
|
||||
|
||||
type LastReadMap = Record<string, string>;
|
||||
|
||||
function loadLastReadMap(): LastReadMap {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(LAST_READ_STORAGE_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
return parsed && typeof parsed === 'object' ? (parsed as LastReadMap) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveLastReadMap(map: LastReadMap): void {
|
||||
try {
|
||||
window.localStorage.setItem(LAST_READ_STORAGE_KEY, JSON.stringify(map));
|
||||
} catch {
|
||||
/* quota / private mode */
|
||||
}
|
||||
}
|
||||
|
||||
interface ConversationsContextValue {
|
||||
conversations: ConversationSummary[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
unread: Record<string, number>;
|
||||
totalUnread: number;
|
||||
refresh: () => Promise<void>;
|
||||
markRead: (conversationId: string) => void;
|
||||
setActiveConversation: (conversationId: string | null) => void;
|
||||
}
|
||||
|
||||
const ConversationsContext = createContext<ConversationsContextValue | null>(null);
|
||||
|
||||
export function ConversationsProvider({ children }: { children: ReactNode }) {
|
||||
const { session, profile } = useAuth();
|
||||
const myId = session?.user.id;
|
||||
|
||||
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [unread, setUnread] = useState<Record<string, number>>({});
|
||||
|
||||
const lastReadRef = useRef<LastReadMap>(loadLastReadMap());
|
||||
const activeConvIdRef = useRef<string | null>(null);
|
||||
const presenceRef = useRef(profile?.presenceState ?? 'offline');
|
||||
const conversationsRef = useRef<ConversationSummary[]>([]);
|
||||
conversationsRef.current = conversations;
|
||||
|
||||
useEffect(() => {
|
||||
presenceRef.current = profile?.presenceState ?? 'offline';
|
||||
}, [profile?.presenceState]);
|
||||
|
||||
const computeUnreadForConv = useCallback(
|
||||
async (convId: string): Promise<number> => {
|
||||
if (!myId) return 0;
|
||||
const since = lastReadRef.current[convId] ?? EPOCH;
|
||||
const { count, error: cErr } = await supabase
|
||||
.from('messages')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('conversation_id', convId)
|
||||
.gt('created_at', since)
|
||||
.neq('sender_id', myId);
|
||||
if (cErr) return 0;
|
||||
return count ?? 0;
|
||||
},
|
||||
[myId],
|
||||
);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!myId) {
|
||||
setConversations([]);
|
||||
setUnread({});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setLoading(true);
|
||||
const convs = await listConversations(supabase);
|
||||
setConversations(convs);
|
||||
const entries = await Promise.all(
|
||||
convs.map(async (c) => [c.id, await computeUnreadForConv(c.id)] as const),
|
||||
);
|
||||
const next: Record<string, number> = {};
|
||||
for (const [id, count] of entries) {
|
||||
// Active conversation is always read.
|
||||
next[id] = id === activeConvIdRef.current ? 0 : count;
|
||||
}
|
||||
setUnread(next);
|
||||
setError(null);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'failed to load conversations');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [myId, computeUnreadForConv]);
|
||||
|
||||
const markRead = useCallback((convId: string) => {
|
||||
const now = new Date().toISOString();
|
||||
lastReadRef.current[convId] = now;
|
||||
saveLastReadMap(lastReadRef.current);
|
||||
setUnread((prev) => (prev[convId] ? { ...prev, [convId]: 0 } : prev));
|
||||
}, []);
|
||||
|
||||
const setActiveConversation = useCallback(
|
||||
(convId: string | null) => {
|
||||
activeConvIdRef.current = convId;
|
||||
if (convId) markRead(convId);
|
||||
},
|
||||
[markRead],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!myId) {
|
||||
setConversations([]);
|
||||
setUnread({});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
void refresh();
|
||||
|
||||
const channel = supabase
|
||||
.channel('conv-ctx:' + myId)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: '*', schema: 'public', table: 'conversation_members' },
|
||||
() => {
|
||||
void refresh();
|
||||
},
|
||||
)
|
||||
.on('postgres_changes', { event: '*', schema: 'public', table: 'conversations' }, () => {
|
||||
void refresh();
|
||||
})
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: 'INSERT', schema: 'public', table: 'messages' },
|
||||
(payload: { new: Record<string, unknown> }) => {
|
||||
const row = payload.new as unknown as { conversation_id: string; sender_id: string };
|
||||
const fromSelf = row.sender_id === myId;
|
||||
const active = row.conversation_id === activeConvIdRef.current;
|
||||
|
||||
if (!fromSelf) {
|
||||
if (active) {
|
||||
// Viewing this conversation — implicit read.
|
||||
markRead(row.conversation_id);
|
||||
} else {
|
||||
setUnread((prev) => ({
|
||||
...prev,
|
||||
[row.conversation_id]: (prev[row.conversation_id] ?? 0) + 1,
|
||||
}));
|
||||
// Notification sound + OS notification — respect DND. Body stays
|
||||
// empty because message content is E2E-encrypted and only
|
||||
// decryptable in the conversation view (not at this hook level).
|
||||
if (presenceRef.current !== 'dnd') {
|
||||
playNotificationTone();
|
||||
const conv = conversationsRef.current.find(
|
||||
(c) => c.id === row.conversation_id,
|
||||
);
|
||||
const sender = conv?.members.find(
|
||||
(m) => m.userId === row.sender_id,
|
||||
)?.profile;
|
||||
const senderName = sender?.displayName ?? '…';
|
||||
const title =
|
||||
conv?.type === 'group'
|
||||
? (conv.name ?? 'Neue Nachricht') + ' · ' + senderName
|
||||
: senderName;
|
||||
void notify({ title, body: 'Neue Nachricht' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-pull conversations to update lastMessageAt ordering.
|
||||
void refresh();
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
void supabase.removeChannel(channel);
|
||||
};
|
||||
}, [myId, refresh, markRead]);
|
||||
|
||||
const totalUnread = useMemo(() => {
|
||||
let s = 0;
|
||||
for (const v of Object.values(unread)) s += v;
|
||||
return s;
|
||||
}, [unread]);
|
||||
|
||||
const value = useMemo<ConversationsContextValue>(
|
||||
() => ({
|
||||
conversations,
|
||||
loading,
|
||||
error,
|
||||
unread,
|
||||
totalUnread,
|
||||
refresh,
|
||||
markRead,
|
||||
setActiveConversation,
|
||||
}),
|
||||
[
|
||||
conversations,
|
||||
loading,
|
||||
error,
|
||||
unread,
|
||||
totalUnread,
|
||||
refresh,
|
||||
markRead,
|
||||
setActiveConversation,
|
||||
],
|
||||
);
|
||||
|
||||
return <ConversationsContext.Provider value={value}>{children}</ConversationsContext.Provider>;
|
||||
}
|
||||
|
||||
export function useConversationsContext(): ConversationsContextValue {
|
||||
const ctx = useContext(ConversationsContext);
|
||||
if (!ctx) throw new Error('useConversationsContext must be used inside <ConversationsProvider>');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { type Friendship } from '@chat-app/shared/friends';
|
||||
import { createContext, type ReactNode, useContext, useMemo } from 'react';
|
||||
|
||||
import { useFriendships } from '../lib/useFriendships';
|
||||
import { useAuth } from './AuthContext';
|
||||
|
||||
interface FriendshipsContextValue {
|
||||
friendships: Friendship[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
acceptedCount: number;
|
||||
outgoingCount: number;
|
||||
incomingCount: number;
|
||||
}
|
||||
|
||||
const FriendshipsContext = createContext<FriendshipsContextValue | null>(null);
|
||||
|
||||
// Single source of truth for the friendships list. Subscribes once via the
|
||||
// realtime channel and shares derived counters (accepted / outgoing / incoming)
|
||||
// with the sidebar badge AND the FriendsPage.
|
||||
export function FriendshipsProvider({ children }: { children: ReactNode }) {
|
||||
const { session } = useAuth();
|
||||
const { friendships, loading, error, refresh } = useFriendships(session?.user.id);
|
||||
|
||||
const value = useMemo<FriendshipsContextValue>(() => {
|
||||
let accepted = 0;
|
||||
let outgoing = 0;
|
||||
let incoming = 0;
|
||||
for (const f of friendships) {
|
||||
if (f.status === 'accepted') accepted++;
|
||||
else if (f.status === 'pending' && f.direction === 'outgoing') outgoing++;
|
||||
else if (f.status === 'pending' && f.direction === 'incoming') incoming++;
|
||||
}
|
||||
return {
|
||||
friendships,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
acceptedCount: accepted,
|
||||
outgoingCount: outgoing,
|
||||
incomingCount: incoming,
|
||||
};
|
||||
}, [friendships, loading, error, refresh]);
|
||||
|
||||
return <FriendshipsContext.Provider value={value}>{children}</FriendshipsContext.Provider>;
|
||||
}
|
||||
|
||||
export function useFriendshipsContext(): FriendshipsContextValue {
|
||||
const ctx = useContext(FriendshipsContext);
|
||||
if (!ctx) throw new Error('useFriendshipsContext must be used inside <FriendshipsProvider>');
|
||||
return ctx;
|
||||
}
|
||||
Reference in New Issue
Block a user