This commit is contained in:
2026-04-18 23:11:35 +02:00
commit f7cfd2a86e
196 changed files with 35538 additions and 0 deletions
@@ -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;
}