- 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>
138 lines
3.7 KiB
TypeScript
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;
|
|
}
|