332 lines
11 KiB
TypeScript
332 lines
11 KiB
TypeScript
import {
|
|
type ConversationSummary,
|
|
isConversationMuted,
|
|
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 { updateTrayUnread } from '../lib/trayBadge';
|
|
import { getIsWindowFocused, subscribeWindowFocus } from '../lib/windowFocus';
|
|
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');
|
|
// Ref-mirrored window-focus state so the realtime message handler can
|
|
// read it synchronously without triggering a re-render per focus flip.
|
|
// "App focused" = our window is the foreground window in the OS,
|
|
// which is what we actually care about when deciding whether an
|
|
// arriving message was seen by the user or should ping.
|
|
const isWindowFocusedRef = useRef<boolean>(getIsWindowFocused());
|
|
const conversationsRef = useRef<ConversationSummary[]>([]);
|
|
conversationsRef.current = conversations;
|
|
|
|
useEffect(() => {
|
|
presenceRef.current = profile?.presenceState ?? 'offline';
|
|
}, [profile?.presenceState]);
|
|
|
|
useEffect(() => {
|
|
return subscribeWindowFocus((focused) => {
|
|
isWindowFocusedRef.current = focused;
|
|
// Regaining focus on the active conversation: the user is back
|
|
// and looking at those messages, so flip the unread counter to
|
|
// zero and persist the last-read stamp. Without this, opening
|
|
// the app after being away leaves a stale badge until the user
|
|
// re-clicks the conversation.
|
|
if (focused) {
|
|
const active = activeConvIdRef.current;
|
|
if (active) {
|
|
const now = new Date().toISOString();
|
|
lastReadRef.current[active] = now;
|
|
saveLastReadMap(lastReadRef.current);
|
|
setUnread((prev) => (prev[active] ? { ...prev, [active]: 0 } : prev));
|
|
}
|
|
}
|
|
});
|
|
}, []);
|
|
|
|
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 {
|
|
// Only flag loading on initial fetch so background re-syncs (visibility
|
|
// change, online event) don't blank the list each time.
|
|
setLoading((prev) => (conversationsRef.current.length === 0 ? true : prev));
|
|
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: 'UPDATE', schema: 'public', table: 'profiles' },
|
|
() => {
|
|
// Peer updated their profile (e.g. uploaded avatar / changed name).
|
|
// Re-pull conversations so members[].profile picks up the new data.
|
|
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;
|
|
const appFocused = isWindowFocusedRef.current;
|
|
// Only suppress sound + unread bump when the user is *actually*
|
|
// looking at the conversation — i.e. the app window is in the
|
|
// foreground AND the active conversation matches. Having the
|
|
// app parked on monitor 2 while the user is in-game still
|
|
// counts as "they didn't see it."
|
|
const seenByUser = active && appFocused;
|
|
|
|
if (!fromSelf) {
|
|
if (seenByUser) {
|
|
markRead(row.conversation_id);
|
|
} else {
|
|
setUnread((prev) => ({
|
|
...prev,
|
|
[row.conversation_id]: (prev[row.conversation_id] ?? 0) + 1,
|
|
}));
|
|
// Notification sound + OS notification — respect DND and
|
|
// per-conversation mute. Body stays empty because message
|
|
// content is E2E-encrypted and only decryptable in the
|
|
// conversation view (not at this hook level).
|
|
const convForMute = conversationsRef.current.find(
|
|
(c) => c.id === row.conversation_id,
|
|
);
|
|
const muted = isConversationMuted(convForMute?.mutedUntil ?? null);
|
|
// "Mentions only" silences non-mention messages here. Mentions
|
|
// still fire via the independent useMentionNotifications
|
|
// subscription on message_mentions, so this branch doesn't
|
|
// lose the @-alerts.
|
|
const mentionsOnly = convForMute?.mentionsOnly ?? false;
|
|
if (presenceRef.current !== 'dnd' && !muted && !mentionsOnly) {
|
|
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();
|
|
|
|
// Windows WebView2 aggressively throttles background WebSockets and
|
|
// sometimes drops events entirely while the window is minimised. Force a
|
|
// refresh + realtime reconnect on visibility/focus regain so we never
|
|
// leave stale conversation lists on a Windows client after the user
|
|
// returns to the app.
|
|
let lastAwakeRefresh = 0;
|
|
const AWAKE_THROTTLE_MS = 30_000;
|
|
const onAwake = () => {
|
|
if (document.visibilityState !== 'visible') return;
|
|
const now = Date.now();
|
|
if (now - lastAwakeRefresh < AWAKE_THROTTLE_MS) return;
|
|
lastAwakeRefresh = now;
|
|
void refresh();
|
|
try {
|
|
// If the socket got wedged during background throttle, a no-op
|
|
// unsubscribe+resubscribe brings it back. `subscribe()` on an already
|
|
// joined channel is a no-op so this is safe.
|
|
channel.subscribe();
|
|
} catch {
|
|
/* ignore — already live */
|
|
}
|
|
};
|
|
document.addEventListener('visibilitychange', onAwake);
|
|
window.addEventListener('online', onAwake);
|
|
|
|
return () => {
|
|
document.removeEventListener('visibilitychange', onAwake);
|
|
window.removeEventListener('online', onAwake);
|
|
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]);
|
|
|
|
// Mirror unread count into the tray tooltip + dock badge. Runs in Tauri
|
|
// only; no-op in browser-preview.
|
|
useEffect(() => {
|
|
void updateTrayUnread(totalUnread);
|
|
}, [totalUnread]);
|
|
|
|
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;
|
|
}
|