chahinebrini 92ad4c93b5 fix(dm): smooth image lightbox + stable online/typing status
- MediaLightbox component extracted from dm.tsx. Image now fills a fixed
  full-screen box with contentFit=contain instead of an onLoad-computed
  aspect ratio, removing the square->real-size jump ("jitter") on open.
- Info-sheet images: render a nested MediaLightbox inside the FormSheet
  (stacks above the sheet modal) and track lightboxSource. Removes the
  close-sheet-then-reopen workaround that switched context back to the DM.
- Typing indicator: heartbeat (every 2s while focused + non-empty) instead
  of keystroke-only sends, so "typing…" holds through thinking pauses;
  receiver clear raised to 6s. stop on blur/send/empty.
- Presence: debounce going offline by 12s (online immediate) so brief
  presence-sync gaps no longer flicker "Online" <-> "last seen".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 10:48:00 +02:00

138 lines
3.7 KiB
TypeScript

import { createContext, useContext, useEffect, useRef, useState } from 'react';
import type { RealtimeChannel } from '@supabase/supabase-js';
import { supabase } from '../lib/supabase';
type OnlinePresenceContext = {
onlineUserIds: Set<string>;
isOnline: (userId: string) => boolean;
};
export const OnlinePresenceContext = createContext<OnlinePresenceContext>({
onlineUserIds: new Set(),
isOnline: () => false,
});
export function useOnlineUsers(): OnlinePresenceContext {
return useContext(OnlinePresenceContext);
}
let sharedChannel: RealtimeChannel | null = null;
let subscriberCount = 0;
let onlineUserIds: Set<string> = new Set();
const listeners = new Set<(ids: Set<string>) => void>();
// Offline-Debounce: Presence-Sync kann kurz aussetzen (Reconnect, Sync-Timing).
// Ohne Grace-Period springt der DM-Header dann sofort von „Online" auf „zuletzt
// online vor X" und zurück → sichtbares Flackern. Online-Werden ist sofort,
// Offline-Werden erst nach OFFLINE_GRACE_MS, falls der User bis dahin nicht
// wieder in der Presence auftaucht.
const OFFLINE_GRACE_MS = 12_000;
const pendingRemoval = new Map<string, ReturnType<typeof setTimeout>>();
function clearPendingRemovals() {
pendingRemoval.forEach((timer) => clearTimeout(timer));
pendingRemoval.clear();
}
function notify() {
const snapshot = new Set(onlineUserIds);
listeners.forEach((fn) => fn(snapshot));
}
function applyRawPresence(rawKeys: string[]) {
const raw = new Set(rawKeys);
let changed = false;
// Anwesende: sofort online + jede geplante Entfernung abbrechen.
for (const id of raw) {
const pending = pendingRemoval.get(id);
if (pending) {
clearTimeout(pending);
pendingRemoval.delete(id);
}
if (!onlineUserIds.has(id)) {
onlineUserIds.add(id);
changed = true;
}
}
// Verschwundene: erst nach Grace-Period entfernen (nicht sofort).
for (const id of onlineUserIds) {
if (!raw.has(id) && !pendingRemoval.has(id)) {
const timer = setTimeout(() => {
onlineUserIds.delete(id);
pendingRemoval.delete(id);
notify();
}, OFFLINE_GRACE_MS);
pendingRemoval.set(id, timer);
}
}
if (changed) notify();
}
function ensureChannel(currentUserId: string) {
if (sharedChannel) return;
const ch = supabase.channel('presence:online', {
config: { presence: { key: currentUserId } },
});
sharedChannel = ch;
ch
.on('presence', { event: 'sync' }, () => {
const state = ch.presenceState();
applyRawPresence(Object.keys(state));
})
.subscribe(async (status: string) => {
if (status === 'SUBSCRIBED') {
await ch.track({ userId: currentUserId, online_at: new Date().toISOString() });
}
});
}
function teardownChannel() {
if (!sharedChannel) return;
clearPendingRemovals();
sharedChannel.untrack().catch(() => {});
supabase.removeChannel(sharedChannel);
sharedChannel = null;
onlineUserIds = new Set();
notify();
}
export function untrackSelf() {
sharedChannel?.untrack().catch(() => {});
}
export function retrackSelf(currentUserId: string) {
sharedChannel
?.track({ userId: currentUserId, online_at: new Date().toISOString() })
.catch(() => {});
}
export function useOnlinePresenceNode(currentUserId: string | null | undefined) {
const [ids, setIds] = useState<Set<string>>(new Set(onlineUserIds));
useEffect(() => {
if (!currentUserId) return;
subscriberCount++;
ensureChannel(currentUserId);
const listener = (next: Set<string>) => setIds(next);
listeners.add(listener);
return () => {
listeners.delete(listener);
subscriberCount--;
if (subscriberCount <= 0) {
subscriberCount = 0;
teardownChannel();
}
};
}, [currentUserId]);
return ids;
}