Calls: an incoming call that ended without the in-app /call screen ever mounting (iOS shows the native CallKit banner, not our screen) left the call store stuck in 'ended' forever — the ended→idle reset only lived in the /call screen. A stuck 'ended' then blocked every subsequent incoming call (RING + VoIP push were received but dropped by the status!=='idle' guard), so accepting from the banner produced a phantom CallKit call that ticked as active with no connection, and the caller saw a missed call. - store self-heals back to 'idle' after a call ends (teardown fallback) - receiveIncoming + ring handler tolerate a stale 'ended' state - onAnswer ends the native CallKit call when store has no incoming call - RNCallKeep.endAllCalls() on launch clears leftover CallKit zombies DM online dot: the green avatar dot used follow-gated presence while the "online" text used raw presence → dot hidden for non-followed partners even when online. DM header avatar now uses raw presence (rawPresence prop) → consistent with the text on both platforms. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
127 lines
3.6 KiB
TypeScript
127 lines
3.6 KiB
TypeScript
import { useState } from 'react';
|
|
import { View, Text } from 'react-native';
|
|
import { Image } from 'expo-image';
|
|
import { useOnlineUsers } from '../hooks/useOnlineUsers';
|
|
import { resolveAvatar } from '../lib/resolveAvatar';
|
|
import { useColors } from '../lib/theme';
|
|
|
|
type Size = 'sm' | 'md' | 'lg' | 'xl';
|
|
|
|
type Props = {
|
|
userId: string | null;
|
|
avatar: string | null;
|
|
nickname: string;
|
|
size?: Size;
|
|
showOnlineIndicator?: boolean;
|
|
isBot?: boolean;
|
|
// Online-Punkt OHNE Follow-Gate (rohe Presence). Für Kontexte wo die Beziehung
|
|
// bereits etabliert ist (z.B. DM-Header) — sonst zeigt der Punkt nur bei
|
|
// gefolgten Usern, was inkonsistent zum „online"-Text wäre (der ist ungated).
|
|
rawPresence?: boolean;
|
|
};
|
|
|
|
const SIZE_MAP: Record<
|
|
Size,
|
|
{ avatar: number; dot: number; border: number; font: number; inset: number }
|
|
> = {
|
|
// inset = bottom/right Offset, berechnet via `avatarRadius*0.293 - dotRadius`
|
|
// damit der Dot-Center exakt auf der Avatar-Perimeter bei 45° sitzt (4:30
|
|
// clock position). Konsistente Insta-Optik unabhängig vom Avatar-Size.
|
|
sm: { avatar: 28, dot: 8, border: 2, font: 11, inset: 0 },
|
|
md: { avatar: 40, dot: 11, border: 2.5, font: 14, inset: 0 },
|
|
lg: { avatar: 56, dot: 14, border: 3, font: 18, inset: 1 },
|
|
xl: { avatar: 96, dot: 18, border: 3, font: 32, inset: 5 },
|
|
};
|
|
|
|
function OnlineDot({ size, bgColor }: { size: Size; bgColor: string }) {
|
|
const s = SIZE_MAP[size];
|
|
return (
|
|
<View
|
|
style={{
|
|
position: 'absolute',
|
|
bottom: s.inset,
|
|
right: s.inset,
|
|
width: s.dot,
|
|
height: s.dot,
|
|
borderRadius: s.dot / 2,
|
|
backgroundColor: '#22c55e',
|
|
borderWidth: s.border,
|
|
borderColor: bgColor,
|
|
shadowColor: '#22c55e',
|
|
shadowOpacity: 0.3,
|
|
shadowRadius: 2,
|
|
shadowOffset: { width: 0, height: 0 },
|
|
elevation: 2,
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export function UserAvatar({
|
|
userId,
|
|
avatar,
|
|
nickname,
|
|
size = 'md',
|
|
showOnlineIndicator = true,
|
|
isBot = false,
|
|
rawPresence = false,
|
|
}: Props) {
|
|
const colors = useColors();
|
|
const { isOnline, onlineUserIds } = useOnlineUsers();
|
|
const [imageFailed, setImageFailed] = useState(false);
|
|
|
|
const s = SIZE_MAP[size];
|
|
const radius = s.avatar / 2;
|
|
|
|
const hasImage = !!avatar && !isBot && !imageFailed;
|
|
const avatarUrl = hasImage ? resolveAvatar(avatar, nickname) : '';
|
|
const initials = (nickname.charAt(0) + (nickname.charAt(1) ?? '')).toUpperCase() || '?';
|
|
|
|
const showDot =
|
|
showOnlineIndicator !== false &&
|
|
!!userId &&
|
|
!isBot &&
|
|
(rawPresence ? onlineUserIds.has(userId) : isOnline(userId));
|
|
|
|
return (
|
|
<View style={{ position: 'relative', width: s.avatar, height: s.avatar }}>
|
|
{hasImage ? (
|
|
<Image
|
|
source={{ uri: avatarUrl }}
|
|
onError={() => setImageFailed(true)}
|
|
style={{
|
|
width: s.avatar,
|
|
height: s.avatar,
|
|
borderRadius: radius,
|
|
backgroundColor: colors.surfaceElevated,
|
|
}}
|
|
contentFit="cover"
|
|
/>
|
|
) : (
|
|
<View
|
|
style={{
|
|
width: s.avatar,
|
|
height: s.avatar,
|
|
borderRadius: radius,
|
|
backgroundColor: colors.brandOrange,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
color: '#ffffff',
|
|
fontSize: s.font,
|
|
fontFamily: 'Nunito_700Bold',
|
|
}}
|
|
>
|
|
{initials}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
|
|
{showDot && <OnlineDot size={size} bgColor={colors.bg} />}
|
|
</View>
|
|
);
|
|
}
|