Online-Status (Phase 1+):
- UserAvatar mit 4 Size-Variants (sm/md/lg/xl) + integrierter Online-Dot
- OnlinePresenceProvider: Supabase-Channel + Following-Filter
- ChatHeaderStatus: "Online" neutral / "vor X min" offline
- useLastSeen + Heartbeat (60s interval + AppState-background ping)
- Privatsphäre-Toggle in profile/index
Sheets:
- FormSheet Android-keyboard-fix (Dimensions.get('screen'), kein
useWindowDimensions-Kollaps), useKeyboardHandler statt manual
Keyboard.addListener, state-reset on re-open
- PostCommentsSheet same Pattern + close-after-submit + drag bis under
app-header
- ConnectMailSheet form-view refactor: scrollable, AES-Banner als
footnote, field-order email→pw→label, fixed 0.85 über alle Steps
Chat:
- DmChatBackground iOS klecks fix (G transform statt nested Svg)
- ChatInput Lyra-1:1 (keyboardWillShow, surfaceElevated bubble,
arrow-up send, attachment links)
- dm/room/chat headers + conversation-list nutzen UserAvatar
- Foreign-Profile "Nachricht"-Button öffnet richtige DM
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
101 lines
2.7 KiB
TypeScript
101 lines
2.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>();
|
|
|
|
function notify() {
|
|
const snapshot = new Set(onlineUserIds);
|
|
listeners.forEach((fn) => fn(snapshot));
|
|
}
|
|
|
|
function ensureChannel(currentUserId: string) {
|
|
if (sharedChannel) {
|
|
console.log('[presence] channel already exists, skip ensure');
|
|
return;
|
|
}
|
|
|
|
console.log('[presence] ensureChannel — opening for user', currentUserId);
|
|
const ch = supabase.channel('presence:online', {
|
|
config: { presence: { key: currentUserId } },
|
|
});
|
|
sharedChannel = ch;
|
|
|
|
ch
|
|
.on('presence', { event: 'sync' }, () => {
|
|
const state = ch.presenceState();
|
|
const keys = Object.keys(state);
|
|
onlineUserIds = new Set(keys);
|
|
console.log('[presence] sync — online users:', keys.length, keys);
|
|
notify();
|
|
})
|
|
.subscribe(async (status: string) => {
|
|
console.log('[presence] subscribe status:', status);
|
|
if (status === 'SUBSCRIBED') {
|
|
const result = await ch.track({ userId: currentUserId, online_at: new Date().toISOString() });
|
|
console.log('[presence] track result:', result);
|
|
}
|
|
});
|
|
}
|
|
|
|
function teardownChannel() {
|
|
if (!sharedChannel) return;
|
|
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;
|
|
}
|