chahinebrini 5c539f8937 feat(presence,sheets,chat): tester-build polish bundle
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>
2026-05-18 08:06:47 +02:00

121 lines
3.2 KiB
TypeScript

import { useState } from 'react';
import { View, Text, Image } from 'react-native';
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;
};
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,
}: Props) {
const colors = useColors();
const { isOnline } = 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 &&
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,
}}
resizeMode="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>
);
}