serverAssets approach didn't bundle the template into the Nitro output (no .output-staging/server/chunks/raw/ dir, no asset-storage mount in nitro.mjs). Logs confirm: '[Magic] Profile template missing in serverAssets'. Drop serverAssets entirely. Inline the template (~2KB) as a TS constant in backend/server/utils/magic-profile-template.ts. Build- robust, no FS/storage dependency at runtime. Canonical source of truth remains ops/mdm/rebreak-mac-dns-filter.mobileconfig — keep in sync manually until/unless we add a codegen step.
94 lines
3.4 KiB
TypeScript
94 lines
3.4 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
import { Text, View, Animated, Easing } from 'react-native';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useOnlineUsers } from '../../hooks/useOnlineUsers';
|
|
import { useLastSeenBatch } from '../../hooks/useLastSeenBatch';
|
|
|
|
type Props = {
|
|
userId: string;
|
|
/** Partner tippt gerade → überschreibt Online/Last-Seen mit „schreibt …". */
|
|
typing?: boolean;
|
|
};
|
|
|
|
const STATUS_COLOR = '#a3a3a3';
|
|
|
|
function formatLastSeen(ts: string, t: (key: string, opts?: Record<string, unknown>) => string): string {
|
|
const diff = Date.now() - new Date(ts).getTime();
|
|
if (diff < 60_000) return t('presence.just_now');
|
|
if (diff < 3_600_000) return t('presence.minutes_ago', { minutes: Math.floor(diff / 60_000) });
|
|
if (diff < 86_400_000) return t('presence.hours_ago', { hours: Math.floor(diff / 3_600_000) });
|
|
return t('presence.days_ago', { days: Math.floor(diff / 86_400_000) });
|
|
}
|
|
|
|
/** Drei pulsierende Punkte (WA/Insta-Style) neben dem „schreibt"-Text. */
|
|
function TypingDots() {
|
|
const dots = useRef([new Animated.Value(0.3), new Animated.Value(0.3), new Animated.Value(0.3)]).current;
|
|
|
|
useEffect(() => {
|
|
const loops = dots.map((d, i) =>
|
|
Animated.loop(
|
|
Animated.sequence([
|
|
Animated.delay(i * 160),
|
|
Animated.timing(d, { toValue: 1, duration: 320, easing: Easing.inOut(Easing.ease), useNativeDriver: true }),
|
|
Animated.timing(d, { toValue: 0.3, duration: 320, easing: Easing.inOut(Easing.ease), useNativeDriver: true }),
|
|
Animated.delay((dots.length - 1 - i) * 160),
|
|
]),
|
|
),
|
|
);
|
|
loops.forEach((l) => l.start());
|
|
return () => loops.forEach((l) => l.stop());
|
|
}, [dots]);
|
|
|
|
return (
|
|
<View style={{ flexDirection: 'row', alignItems: 'center', marginLeft: 4, gap: 2 }}>
|
|
{dots.map((d, i) => (
|
|
<Animated.View
|
|
key={i}
|
|
style={{ width: 3, height: 3, borderRadius: 1.5, backgroundColor: STATUS_COLOR, opacity: d }}
|
|
/>
|
|
))}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
export function ChatHeaderStatus({ userId, typing }: Props) {
|
|
const { t } = useTranslation();
|
|
// DM-Header zeigt den ECHTEN Presence-Status des Partners (wie WhatsApp) —
|
|
// NICHT die following-gated `isOnline`-Variante aus dem Feed/Profil. Wer dir
|
|
// schreibt, sieht ohnehin via Typing-Indicator dass du da bist. `onlineUserIds`
|
|
// ist der rohe Presence-Set → updatet live über den Presence-Sync-Channel.
|
|
const { onlineUserIds } = useOnlineUsers();
|
|
const online = onlineUserIds.has(userId);
|
|
const lastSeenMap = useLastSeenBatch(online ? [] : [userId]);
|
|
|
|
if (typing) {
|
|
return (
|
|
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
|
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: STATUS_COLOR }}>
|
|
{t('presence.typing')}
|
|
</Text>
|
|
<TypingDots />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (online) {
|
|
// User-Wunsch: „Online"-Text zeigen, aber NICHT grün (Dot im Avatar reicht
|
|
// als Farb-Signal). Neutraler `textMuted`-Grau-Ton.
|
|
return (
|
|
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: STATUS_COLOR }}>
|
|
{t('presence.online')}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
const lastSeen = lastSeenMap[userId];
|
|
if (!lastSeen) return null;
|
|
|
|
return (
|
|
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: STATUS_COLOR }}>
|
|
{formatLastSeen(lastSeen, t)}
|
|
</Text>
|
|
);
|
|
}
|