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>
This commit is contained in:
chahinebrini 2026-05-18 08:06:47 +02:00
parent 19b569927a
commit 5c539f8937
27 changed files with 1324 additions and 678 deletions

View File

@ -1,4 +1,4 @@
import { useState, useCallback, useEffect } from 'react';
import { useCallback, useState } from 'react';
import {
View,
Text,
@ -6,7 +6,6 @@ import {
TouchableOpacity,
TextInput,
ActivityIndicator,
Image,
RefreshControl,
StyleSheet,
} from 'react-native';
@ -15,8 +14,8 @@ import { Ionicons } from '@expo/vector-icons';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { apiFetch } from '../../lib/api';
import { resolveAvatar } from '../../lib/resolveAvatar';
import { AppHeader } from '../../components/AppHeader';
import { UserAvatar } from '../../components/UserAvatar';
import { useColors } from '../../lib/theme';
type DmConversation = {
@ -43,25 +42,15 @@ function DmItem({ conv, onPress }: { conv: DmConversation; onPress: () => void }
const styles = makeStyles(colors);
const hasUnread = conv.unreadCount > 0;
const avatarUrl = resolveAvatar(conv.partnerAvatar, conv.partnerName);
const [avatarLoadFailed, setAvatarLoadFailed] = useState(false);
useEffect(() => { setAvatarLoadFailed(false); }, [avatarUrl]);
const avatarInitials = (conv.partnerName.slice(0, 2)).toUpperCase() || '?';
return (
<TouchableOpacity onPress={onPress} activeOpacity={0.7}>
<View style={styles.dmRow}>
<View style={styles.dmAvatar}>
{!avatarLoadFailed ? (
<Image
source={{ uri: avatarUrl }}
style={styles.dmAvatarImg}
onError={() => setAvatarLoadFailed(true)}
/>
) : (
<Text style={styles.dmAvatarInitials}>{avatarInitials}</Text>
)}
</View>
<UserAvatar
userId={conv.partnerId}
avatar={conv.partnerAvatar}
nickname={conv.partnerName}
size="md"
/>
<View style={styles.dmInfo}>
<View style={styles.dmHeaderRow}>
<Text style={styles.dmName} numberOfLines={1}>
@ -229,6 +218,7 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
dmRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
paddingHorizontal: 16,
paddingVertical: 12,
backgroundColor: colors.bg,

View File

@ -3,6 +3,7 @@ import { AppState, I18nManager } from 'react-native';
I18nManager.allowRTL(true);
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import * as Notifications from 'expo-notifications';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
@ -29,6 +30,7 @@ import { useLyraVoiceStore } from '../stores/lyraVoice';
import { BrandSplash } from '../components/BrandSplash';
import { AppLockGate } from '../components/AppLockGate';
import { DeviceLimitReachedSheet } from '../components/DeviceLimitReachedSheet';
import { OnlinePresenceProvider } from '../components/OnlinePresenceProvider';
import '../lib/i18n'; // i18next-Init via Side-Effect
import '../global.css';
@ -104,6 +106,7 @@ function RootLayoutInner() {
}
return (
<OnlinePresenceProvider>
<AppLockGate>
<StatusBar style={colorScheme === 'dark' ? 'light' : 'dark'} />
<DeviceLimitReachedSheet />
@ -200,6 +203,7 @@ function RootLayoutInner() {
/>
</Stack>
</AppLockGate>
</OnlinePresenceProvider>
);
}

View File

@ -6,10 +6,10 @@ import {
TouchableOpacity,
Platform,
ActivityIndicator,
Image,
StyleSheet,
Keyboard,
KeyboardAvoidingView,
} from 'react-native';
import { KeyboardAvoidingView } from 'react-native-keyboard-controller';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter, useLocalSearchParams } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
@ -18,12 +18,13 @@ import { useTranslation } from 'react-i18next';
import { apiFetch } from '../lib/api';
import { supabase } from '../lib/supabase';
import { ChatBubble, type ChatMsg } from '../components/chat/ChatBubble';
import { resolveAvatar } from '../lib/resolveAvatar';
import { ChatInput, type SendPayload } from '../components/chat/ChatInput';
import { DmChatBackground } from '../components/chat/DmChatBackground';
import { useDmRealtime } from '../hooks/useChatRealtime';
import { useColors } from '../lib/theme';
import { useThemeStore } from '../stores/theme';
import { UserAvatar } from '../components/UserAvatar';
import { ChatHeaderStatus } from '../components/chat/ChatHeaderStatus';
type DmHistoryResponse = {
partner: {
@ -66,6 +67,7 @@ export default function DmScreen() {
const { userId } = useLocalSearchParams<{ userId: string }>();
const [keyboardHeight, setKeyboardHeight] = useState(0);
const [messages, setMessages] = useState<ChatMsg[]>([]);
const [partner, setPartner] = useState<DmHistoryResponse['partner'] | null>(null);
const partnerRef = useRef<DmHistoryResponse['partner'] | null>(null);
@ -74,6 +76,17 @@ export default function DmScreen() {
);
const [sending, setSending] = useState(false);
useEffect(() => {
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
const show = Keyboard.addListener(showEvent, (e) => setKeyboardHeight(e.endCoordinates.height));
const hide = Keyboard.addListener(hideEvent, () => setKeyboardHeight(0));
return () => {
show.remove();
hide.remove();
};
}, []);
// Reset aller conversation-spezifischen States wenn userId wechselt (Stack-Reuse)
useEffect(() => {
setMessages([]);
@ -258,24 +271,26 @@ export default function DmScreen() {
<Ionicons name="chevron-back" size={22} color={colors.text} />
</TouchableOpacity>
<View style={styles.headerCenter}>
<View style={styles.headerAvatar}>
{partner?.avatar ? (
<Image source={{ uri: resolveAvatar(partner.avatar, partner.nickname ?? '') }} style={styles.headerAvatarImg} />
) : (
<Text style={styles.headerAvatarInitials}>
{(partner?.nickname ?? '?').slice(0, 2).toUpperCase()}
</Text>
)}
<View style={{ marginRight: 8 }}>
<UserAvatar
userId={userId ?? null}
avatar={partner?.avatar ?? null}
nickname={partner?.nickname ?? '?'}
size="md"
/>
</View>
<View style={{ flexShrink: 1 }}>
<Text style={styles.headerName} numberOfLines={1}>
{partner?.nickname ?? '…'}
</Text>
{userId && <ChatHeaderStatus userId={userId} />}
</View>
<Text style={styles.headerName} numberOfLines={1}>
{partner?.nickname ?? '…'}
</Text>
</View>
</View>
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior="padding"
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={0}
>
<View style={{ flex: 1, backgroundColor: chatBg }}>
@ -312,7 +327,7 @@ export default function DmScreen() {
)}
</View>
<View style={{ paddingBottom: Math.max(insets.bottom - 8, 0) }}>
<View style={{ paddingBottom: keyboardHeight > 0 ? 8 : Math.max(12, insets.bottom), backgroundColor: colors.bg }}>
<ChatInput
replyTo={replyTo}
sending={sending}
@ -338,10 +353,7 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
borderBottomColor: colors.border,
},
backBtn: {
width: 36,
height: 36,
borderRadius: 12,
backgroundColor: colors.surfaceElevated,
padding: 8,
alignItems: 'center',
justifyContent: 'center',
},
@ -351,22 +363,6 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
alignItems: 'center',
marginLeft: 8,
},
headerAvatar: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
marginRight: 8,
},
headerAvatarImg: { width: 32, height: 32 },
headerAvatarInitials: {
fontSize: 11,
fontFamily: 'Nunito_700Bold',
color: colors.textMuted,
},
headerName: {
fontSize: 15,
fontFamily: 'Nunito_700Bold',

View File

@ -1,55 +1,44 @@
import { useState } from 'react';
import { View, Text, ScrollView, TouchableOpacity, Image } from 'react-native';
import { useState, useEffect, useCallback } from 'react';
import { View, Text, ScrollView, TouchableOpacity, Alert, ActivityIndicator } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useQuery } from '@tanstack/react-query';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { useColors } from '../../lib/theme';
import { resolveAvatar } from '../../lib/resolveAvatar';
import type { Plan } from '../../hooks/useUserPlan';
import { apiFetch } from '../../lib/api';
import { UserAvatar } from '../../components/UserAvatar';
import { PostCard } from '../../components/PostCard';
import { PostCardSkeleton } from '../../components/PostCardSkeleton';
import { PostCommentsSheet } from '../../components/PostCommentsSheet';
import { type CommunityPost } from '../../stores/community';
const planLabel: Record<Plan, string> = {
free: 'Free',
pro: 'Pro',
legend: 'Legend',
};
const planColors: Record<Plan, { bg: string; text: string; border: string }> = {
free: { bg: '#f5f5f5', text: '#525252', border: '#e5e5e5' },
pro: { bg: '#fff7ed', text: '#c2410c', border: '#fed7aa' },
legend: { bg: '#fef9c3', text: '#854d0e', border: '#fde68a' },
};
// TODO: GET /api/social/profile/[userId] — extend response um approvedDomainsCount.
// Strikt anonym: nur nickname, avatar, plan, memberSince, postsCount, followersCount,
// approvedDomainsCount, isFollowing. NIEMALS email, demographics, cooldowns, sos-insights.
type ForeignProfile = {
id: string;
nickname: string;
avatar: string | null;
plan: Plan;
memberSince: string;
tier: string;
totalPoints: number;
postsCount: number;
followersCount: number;
followingCount: number;
approvedDomainsCount: number;
isFollowing: boolean;
isSelf: boolean;
joinedAt: string;
recentPosts: unknown[];
};
const DUMMY_FOREIGN: ForeignProfile = {
id: 'foreign-user-id',
nickname: 'Jonas_42',
avatar: 'wolf',
plan: 'pro',
memberSince: 'April 2026',
postsCount: 12,
followersCount: 47,
approvedDomainsCount: 8,
isFollowing: false,
};
function formatJoinedAt(iso: string): string {
try {
const d = new Date(iso);
return d.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
} catch {
return '';
}
}
type StatProps = {
value: string;
label: string;
};
type StatProps = { value: string; label: string };
function ForeignStat({ value, label }: StatProps) {
const colors = useColors();
@ -69,14 +58,7 @@ function ForeignStat({ value, label }: StatProps) {
<Text style={{ fontSize: 22, color: colors.text, fontFamily: 'Nunito_700Bold' }}>
{value}
</Text>
<Text
style={{
marginTop: 2,
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_600SemiBold',
}}
>
<Text style={{ marginTop: 2, fontSize: 11, color: colors.textMuted, fontFamily: 'Nunito_600SemiBold' }}>
{label}
</Text>
</View>
@ -87,18 +69,118 @@ export default function ForeignProfileScreen() {
const insets = useSafeAreaInsets();
const router = useRouter();
const colors = useColors();
const { t } = useTranslation();
const { userId } = useLocalSearchParams<{ userId: string }>();
const [imageFailed, setImageFailed] = useState(false);
const [isFollowing, setIsFollowing] = useState(DUMMY_FOREIGN.isFollowing);
// TODO: useQuery → apiFetch(`/api/social/profile/${userId}`)
const profile = DUMMY_FOREIGN;
void userId;
const [isFollowing, setIsFollowing] = useState(false);
const [localFollowersCount, setLocalFollowersCount] = useState<number | null>(null);
const [followPending, setFollowPending] = useState(false);
const [activeCommentsPostId, setActiveCommentsPostId] = useState<string | null>(null);
const avatarUrl = resolveAvatar(profile.avatar, profile.nickname);
const initials = profile.nickname.slice(0, 2).toUpperCase();
const showImage = !!profile.avatar && !imageFailed;
const planStyle = planColors[profile.plan];
const openComments = useCallback((postId: string) => setActiveCommentsPostId(postId), []);
const closeComments = useCallback(() => setActiveCommentsPostId(null), []);
const { data: profile, isLoading, isError } = useQuery<ForeignProfile>({
queryKey: ['foreign-profile', userId],
queryFn: () => apiFetch<ForeignProfile>(`/api/social/profile/${userId}`),
enabled: !!userId,
});
const { data: userPosts = [], isLoading: postsLoading } = useQuery<CommunityPost[]>({
queryKey: ['community-posts', { userId }],
queryFn: () => apiFetch<CommunityPost[]>(`/api/community/posts?userId=${userId}&limit=20`),
enabled: !!userId,
});
useEffect(() => {
if (!profile) return;
if (profile.isSelf) {
router.replace('/profile');
return;
}
setIsFollowing(profile.isFollowing);
setLocalFollowersCount(profile.followersCount);
}, [profile]);
async function handleFollow() {
if (followPending || !profile) return;
const optimisticFollowing = !isFollowing;
const optimisticCount = (localFollowersCount ?? profile.followersCount) + (optimisticFollowing ? 1 : -1);
setIsFollowing(optimisticFollowing);
setLocalFollowersCount(optimisticCount);
setFollowPending(true);
try {
const res = await apiFetch<{ following: boolean; followersCount: number }>(
'/api/social/follow',
{ method: 'POST', body: { userId: profile.id } },
);
setIsFollowing(res.following);
setLocalFollowersCount(res.followersCount);
} catch {
setIsFollowing(!optimisticFollowing);
setLocalFollowersCount(localFollowersCount ?? profile.followersCount);
Alert.alert(t('common.error'), t('common.unknown_error'));
} finally {
setFollowPending(false);
}
}
if (isLoading) {
return (
<View style={{ flex: 1, backgroundColor: colors.groupedBg }}>
<View
style={{
paddingTop: insets.top,
backgroundColor: colors.groupedBg,
borderBottomWidth: 1,
borderBottomColor: colors.border,
}}
>
<View style={{ height: 56, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12 }}>
<TouchableOpacity onPress={() => router.back()} hitSlop={8} activeOpacity={0.5} style={{ padding: 8 }}>
<Ionicons name="chevron-back" size={22} color={colors.text} />
</TouchableOpacity>
</View>
</View>
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<ActivityIndicator color={colors.brandOrange} />
</View>
</View>
);
}
if (isError || !profile) {
return (
<View style={{ flex: 1, backgroundColor: colors.groupedBg }}>
<View
style={{
paddingTop: insets.top,
backgroundColor: colors.groupedBg,
borderBottomWidth: 1,
borderBottomColor: colors.border,
}}
>
<View style={{ height: 56, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12 }}>
<TouchableOpacity onPress={() => router.back()} hitSlop={8} activeOpacity={0.5} style={{ padding: 8 }}>
<Ionicons name="chevron-back" size={22} color={colors.text} />
</TouchableOpacity>
</View>
</View>
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 24 }}>
<Text style={{ fontSize: 14, color: colors.textMuted, fontFamily: 'Nunito_400Regular', textAlign: 'center', marginBottom: 16 }}>
{t('common.unknown_error')}
</Text>
<TouchableOpacity onPress={() => router.back()} activeOpacity={0.7}>
<Text style={{ fontSize: 14, color: colors.brandOrange, fontFamily: 'Nunito_600SemiBold' }}>
{t('common.back')}
</Text>
</TouchableOpacity>
</View>
</View>
);
}
const displayFollowers = localFollowersCount ?? profile.followersCount;
return (
<View style={{ flex: 1, backgroundColor: colors.groupedBg }}>
@ -119,12 +201,7 @@ export default function ForeignProfileScreen() {
paddingHorizontal: 12,
}}
>
<TouchableOpacity
onPress={() => router.back()}
hitSlop={8}
activeOpacity={0.5}
style={{ padding: 8 }}
>
<TouchableOpacity onPress={() => router.back()} hitSlop={8} activeOpacity={0.5} style={{ padding: 8 }}>
<Ionicons name="chevron-back" size={22} color={colors.text} />
</TouchableOpacity>
<Text style={{ fontSize: 15, color: colors.text, fontFamily: 'Nunito_600SemiBold' }}>
@ -140,76 +217,27 @@ export default function ForeignProfileScreen() {
showsVerticalScrollIndicator={false}
>
<View style={{ alignItems: 'center', paddingVertical: 24, paddingHorizontal: 20 }}>
<View
style={{
width: 96,
height: 96,
borderRadius: 48,
borderWidth: 2,
borderColor: planStyle.border,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
backgroundColor: showImage ? colors.surface : colors.brandOrange,
}}
>
{showImage ? (
<Image
source={{ uri: avatarUrl }}
onError={() => setImageFailed(true)}
style={{ width: 92, height: 92, borderRadius: 46 }}
/>
) : (
<Text style={{ color: '#fff', fontSize: 32, fontFamily: 'Nunito_700Bold' }}>
{initials}
</Text>
)}
<View>
<UserAvatar
userId={userId ?? null}
avatar={profile.avatar}
nickname={profile.nickname}
size="xl"
/>
</View>
<Text
style={{
marginTop: 16,
fontSize: 22,
color: colors.text,
fontFamily: 'Nunito_700Bold',
}}
>
<Text style={{ marginTop: 16, fontSize: 22, color: colors.text, fontFamily: 'Nunito_700Bold' }}>
{profile.nickname}
</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginTop: 8 }}>
<View
style={{
paddingHorizontal: 10,
paddingVertical: 3,
borderRadius: 999,
backgroundColor: planStyle.bg,
borderWidth: 1,
borderColor: planStyle.border,
}}
>
<Text
style={{
fontSize: 11,
color: planStyle.text,
fontFamily: 'Nunito_700Bold',
letterSpacing: 0.4,
}}
>
{planLabel[profile.plan].toUpperCase()}
</Text>
</View>
<Text style={{ fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>
Mitglied seit {profile.memberSince}
</Text>
</View>
<Text style={{ marginTop: 6, fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>
Mitglied seit {formatJoinedAt(profile.joinedAt)}
</Text>
<View style={{ flexDirection: 'row', gap: 8, marginTop: 16, width: '100%' }}>
<TouchableOpacity
onPress={() => {
// TODO: POST /api/social/follow/[userId] resp. DELETE bei unfollow
setIsFollowing((v) => !v);
}}
onPress={handleFollow}
disabled={followPending}
activeOpacity={0.7}
style={{ flex: 1 }}
>
@ -220,23 +248,15 @@ export default function ForeignProfileScreen() {
borderWidth: 1,
borderColor: isFollowing ? colors.border : colors.brandOrange,
alignItems: 'center',
opacity: followPending ? 0.6 : 1,
}}>
<Text
style={{
fontSize: 13,
color: isFollowing ? colors.text : '#ffffff',
fontFamily: 'Nunito_600SemiBold',
}}
>
<Text style={{ fontSize: 13, color: isFollowing ? colors.text : '#ffffff', fontFamily: 'Nunito_600SemiBold' }}>
{isFollowing ? 'Folge ich' : 'Folgen'}
</Text>
</View>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
// TODO: navigate to DM with this userId
router.push(`/dm`);
}}
onPress={() => router.push({ pathname: '/dm', params: { userId: profile.id } })}
activeOpacity={0.7}
style={{ flex: 1 }}
>
@ -248,35 +268,22 @@ export default function ForeignProfileScreen() {
borderColor: colors.border,
alignItems: 'center',
}}>
<Text
style={{
fontSize: 13,
color: colors.text,
fontFamily: 'Nunito_600SemiBold',
}}
>
Nachricht
</Text>
<Text style={{ fontSize: 13, color: colors.text, fontFamily: 'Nunito_600SemiBold' }}>
Nachricht
</Text>
</View>
</TouchableOpacity>
</View>
</View>
<View
style={{
height: 1,
backgroundColor: 'rgba(0,0,0,0.06)',
marginHorizontal: 16,
}}
/>
<View style={{ height: 1, backgroundColor: 'rgba(0,0,0,0.06)', marginHorizontal: 16 }} />
<View style={{ flexDirection: 'row', gap: 8, marginTop: 16, paddingHorizontal: 16 }}>
<ForeignStat value={String(profile.postsCount)} label="Posts" />
<ForeignStat value={String(profile.followersCount)} label="Follower" />
<ForeignStat value={String(displayFollowers)} label="Follower" />
<ForeignStat value={String(profile.approvedDomainsCount)} label="Approved" />
</View>
{/* TODO: GET /api/community/posts?userId=... — letzte 5 Posts */}
<View style={{ marginTop: 24, paddingHorizontal: 16 }}>
<Text
style={{
@ -287,31 +294,43 @@ export default function ForeignProfileScreen() {
marginBottom: 8,
}}
>
LETZTE POSTS
{t('community.recent_posts')}
</Text>
<View
style={{
backgroundColor: colors.card,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 14,
padding: 16,
alignItems: 'center',
}}
>
<Text
{postsLoading ? (
<View>
<PostCardSkeleton />
<PostCardSkeleton />
<PostCardSkeleton />
</View>
) : userPosts.length === 0 ? (
<View
style={{
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
textAlign: 'center',
backgroundColor: colors.card,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 14,
padding: 16,
alignItems: 'center',
}}
>
Posts-Liste folgt in Phase C
</Text>
</View>
<Text style={{ fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular', textAlign: 'center' }}>
{t('community.no_posts')}
</Text>
</View>
) : (
userPosts.map((post) => (
<PostCard key={post.id} post={post} onCommentPress={openComments} />
))
)}
</View>
</ScrollView>
<PostCommentsSheet
postId={activeCommentsPostId}
visible={activeCommentsPostId !== null}
onClose={closeComments}
/>
</View>
);
}

View File

@ -1,5 +1,6 @@
import { useRef, useState } from 'react';
import { View, ScrollView, Text, Alert, findNodeHandle, UIManager } from 'react-native';
import { useRef, useState, useEffect } from 'react';
import { View, ScrollView, Text, Alert, Switch, findNodeHandle, UIManager } from 'react-native';
import { useTranslation } from 'react-i18next';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { AppHeader } from '../../components/AppHeader';
@ -23,6 +24,7 @@ import {
useDemographics,
} from '../../hooks/useProfileData';
import { apiFetch } from '../../lib/api';
import { untrackSelf, retrackSelf } from '../../hooks/useOnlineUsers';
const EMPTY_COOLDOWNS: CooldownEntry[] = [];
@ -88,11 +90,40 @@ export default function ProfileScreen() {
const router = useRouter();
const insets = useSafeAreaInsets();
const colors = useColors();
const { t } = useTranslation();
const [bannerDismissed, setBannerDismissed] = useState(false);
const [demographicsExpanded, setDemographicsExpanded] = useState(false);
const { me } = useMe();
const { user } = useAuthStore();
const [presenceVisible, setPresenceVisible] = useState<boolean>(true);
useEffect(() => {
if (me?.presenceVisible !== undefined) {
setPresenceVisible(me.presenceVisible);
}
}, [me?.presenceVisible]);
async function togglePresence() {
const next = !presenceVisible;
setPresenceVisible(next);
if (!next) {
untrackSelf();
} else if (user?.id) {
retrackSelf(user.id);
}
try {
await apiFetch('/api/me/presence-visibility', { method: 'POST', body: { visible: next } });
} catch {
setPresenceVisible(!next);
if (next) {
untrackSelf();
} else if (user?.id) {
retrackSelf(user.id);
}
}
}
const { stats: socialStats } = useSocialStats(me?.id);
const { domains: approvedDomainsData } = useApprovedDomains();
const { cooldownHistory } = useCooldownHistory();
@ -272,6 +303,41 @@ export default function ProfileScreen() {
<ApprovedDomainsList domains={approvedDomainsData?.list ?? []} />
<View style={{ paddingHorizontal: 16, paddingTop: 24, gap: 8 }}>
<Text
style={{
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_700Bold',
letterSpacing: 0.8,
}}
>
{t('profile.privacy_section_title').toUpperCase()}
</Text>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: colors.card,
padding: 14,
borderRadius: 12,
borderWidth: 1,
borderColor: colors.border,
}}
>
<View style={{ flex: 1, gap: 2 }}>
<Text style={{ fontSize: 14, color: colors.text, fontFamily: 'Nunito_600SemiBold' }}>
{t('profile.show_online_status')}
</Text>
<Text style={{ fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>
{t('profile.show_online_status_hint')}
</Text>
</View>
<Switch value={presenceVisible} onValueChange={togglePresence} />
</View>
</View>
<View style={{ height: 24 }} />
</ScrollView>
</View>

View File

@ -5,7 +5,6 @@ import {
FlatList,
Pressable,
TouchableOpacity,
Image,
Modal,
TextInput,
ActivityIndicator,
@ -13,8 +12,10 @@ import {
Alert,
StyleSheet,
ScrollView,
Keyboard,
KeyboardAvoidingView,
} from 'react-native';
import { KeyboardAvoidingView } from 'react-native-keyboard-controller';
import { Image } from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter, useLocalSearchParams } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
@ -29,6 +30,8 @@ import { ChatBubble, type ChatMsg } from '../components/chat/ChatBubble';
import { ChatInput, type SendPayload } from '../components/chat/ChatInput';
import { useRoomRealtime } from '../hooks/useChatRealtime';
import { useColors } from '../lib/theme';
import { useOnlineUsers } from '../hooks/useOnlineUsers';
import { UserAvatar } from '../components/UserAvatar';
const GROUP_GAP_MS = 5 * 60 * 1000;
@ -70,9 +73,11 @@ export default function RoomScreen() {
const queryClient = useQueryClient();
const flatRef = useRef<FlatList>(null);
const [myUserId, setMyUserId] = useState<string | undefined>();
const { isOnline } = useOnlineUsers();
const { roomId } = useLocalSearchParams<{ roomId: string }>();
const [keyboardHeight, setKeyboardHeight] = useState(0);
const [messages, setMessages] = useState<ChatMsg[]>([]);
const [replyTo, setReplyTo] = useState<{ id: string; nickname: string; content: string } | null>(
null,
@ -86,6 +91,17 @@ export default function RoomScreen() {
supabase.auth.getSession().then(({ data }) => setMyUserId(data.session?.user.id));
}, []);
useEffect(() => {
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
const show = Keyboard.addListener(showEvent, (e) => setKeyboardHeight(e.endCoordinates.height));
const hide = Keyboard.addListener(hideEvent, () => setKeyboardHeight(0));
return () => {
show.remove();
hide.remove();
};
}, []);
const { data, isLoading, refetch } = useQuery<RoomDetail>({
queryKey: ['chat-room', roomId],
queryFn: async () => {
@ -306,7 +322,7 @@ export default function RoomScreen() {
<View style={styles.headerCenter}>
<View style={styles.headerAvatar}>
{room?.avatarUrl ? (
<Image source={{ uri: room.avatarUrl }} style={styles.headerAvatarImg} />
<Image source={{ uri: room.avatarUrl }} style={styles.headerAvatarImg} resizeMode="cover" />
) : (
<Text style={styles.headerAvatarInitials}>{initials}</Text>
)}
@ -315,11 +331,16 @@ export default function RoomScreen() {
<Text style={styles.headerName} numberOfLines={1}>
{room?.name ?? '…'}
</Text>
{room && (
<Text style={styles.headerSub} numberOfLines={1}>
{t('chat.member_count', { n: room.memberCount })}
</Text>
)}
{room && (() => {
const onlineCount = members.filter((m) => isOnline(m.userId)).length;
return (
<Text style={styles.headerSub} numberOfLines={1}>
{onlineCount > 0
? t('chat.member_count_online', { n: room.memberCount, online: onlineCount })
: t('chat.member_count', { n: room.memberCount })}
</Text>
);
})()}
</View>
</View>
<TouchableOpacity style={styles.iconBtn} onPress={() => setSettingsOpen(true)} hitSlop={8} activeOpacity={0.7}>
@ -360,7 +381,8 @@ export default function RoomScreen() {
) : (
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior="padding"
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={0}
>
<FlatList
ref={flatRef}
@ -383,7 +405,7 @@ export default function RoomScreen() {
showsVerticalScrollIndicator={false}
onContentSizeChange={() => flatRef.current?.scrollToEnd({ animated: false })}
/>
<View style={{ paddingBottom: Math.max(insets.bottom - 8, 0) }}>
<View style={{ paddingBottom: keyboardHeight > 0 ? 8 : Math.max(12, insets.bottom), backgroundColor: colors.bg }}>
<ChatInput
replyTo={replyTo}
sending={sending}
@ -534,7 +556,7 @@ function RoomSettingsModal({
style={modal.avatarWrap}
>
{room.avatarUrl ? (
<Image source={{ uri: room.avatarUrl }} style={modal.avatar} />
<Image source={{ uri: room.avatarUrl }} style={modal.avatar} resizeMode="cover" />
) : (
<View style={[modal.avatar, modal.avatarPlaceholder]}>
<Ionicons name="people" size={32} color="#737373" />
@ -595,14 +617,13 @@ function RoomSettingsModal({
</Text>
{members.map((m) => (
<View key={m.userId} style={modal.memberRow}>
<View style={modal.memberAvatar}>
{m.avatar ? (
<Image source={{ uri: m.avatar }} style={modal.memberAvatarImg} />
) : (
<Text style={modal.memberInitials}>
{m.nickname.slice(0, 2).toUpperCase()}
</Text>
)}
<View style={{ marginRight: 10 }}>
<UserAvatar
userId={m.userId}
avatar={m.avatar}
nickname={m.nickname}
size="md"
/>
</View>
<View style={{ flex: 1 }}>
<Text style={modal.memberName}>{m.nickname}</Text>
@ -658,10 +679,7 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
borderBottomColor: colors.border,
},
iconBtn: {
width: 36,
height: 36,
borderRadius: 12,
backgroundColor: colors.surfaceElevated,
padding: 8,
alignItems: 'center',
justifyContent: 'center',
},
@ -825,22 +843,6 @@ function makeModalStyles(colors: ReturnType<typeof useColors>) {
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.border,
},
memberAvatar: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
marginRight: 10,
},
memberAvatarImg: { width: 32, height: 32 },
memberInitials: {
fontSize: 11,
fontFamily: 'Nunito_700Bold',
color: colors.textMuted,
},
memberName: { fontSize: 13, fontFamily: 'Nunito_600SemiBold', color: colors.text },
memberRole: { fontSize: 11, color: colors.textMuted, marginTop: 1, textTransform: 'capitalize' },
actionBtn: {

View File

@ -1,47 +1,48 @@
import { ReactNode, useEffect, useRef, useState } from 'react';
import {
Animated,
Dimensions,
Keyboard,
Modal,
PanResponder,
Platform,
ScrollView,
Text,
TouchableOpacity,
View,
useWindowDimensions,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useKeyboardHandler } from 'react-native-keyboard-controller';
import { runOnJS } from 'react-native-reanimated';
import { useColors } from '../lib/theme';
/**
* App-weites Bottom-Sheet DAS eine Pattern für alle Custom-Modals.
*
* Verallgemeinert das verifizierte `PostCommentsSheet`-Pattern:
* - `<Modal transparent>` mit hellem (oder ganz ohne) Backdrop verdunkelt den
* Main-Screen nie stark.
* - **Standard-Header**: Grabber-Bar mittig + Titel **links**. KEINE
* Fertig"/„Abbrechen"/Zurück"-Buttons Schließen = runterswipen / Backdrop-Tap.
* - **Resizable**: Drag am Handle/Header zieht das Sheet größer/kleiner;
* Drag nach unten unter `minHeightPct` (oder schneller Flick) dismiss.
* - **Höhe 75 % Screen**, IMMER (Drag + Keyboard-Expand sind hart gedeckelt).
* - **Keyboard-aware**: Tastatur auf Sheet wächst um Tastatur-Höhe (gedeckelt),
* `paddingBottom: keyboardHeight` (iOS) schiebt den Inhalt exakt über die
* Tastatur. Android: `windowSoftInputMode=adjustResize` im Manifest macht das.
* - **Default = Auto-Fit**: Sheet misst seinen Inhalt (interner ScrollView via
* `onContentSizeChange`) und wird genau so hoch wie nötig. Reicht der Cap
* nicht, scrollt der Inhalt intern.
* - **Cap**: SCREEN_H statusBar `navHeaderOffset` (default 56dp). So
* überschreitet das Sheet niemals den App-Nav-Header.
* - **Legacy-Mode**: Wer `initialHeightPct` setzt, bekommt das alte
* Fixed-Pct-Layout mit `<View flex:1>` children-wrapper (backwards-compat).
* - **Keyboard (iOS + Android)**: `useKeyboardHandler` aus
* `react-native-keyboard-controller` liefert den Modal-aware nativen
* Keyboard-Frame. Sheet wächst um `keyboardHeight` (gedeckelt),
* `paddingBottom: keyboardHeight` schiebt Inhalt exakt über die Tastatur
* kein Doppel-Compensation auf Android (das war der Bug mit manuellem
* `Keyboard.addListener`-Pattern, da RN-Modal `adjustResize` ignoriert).
* - **Resize per Drag**: Grabber/Header sind drag-area User kann größer
* ziehen (bis Max) oder zum dismissen runterswipen.
*
* Driver-Trennung (sonst Style property 'height' is not supported by native
* animated module"-Crash): äußere View animiert `height` im JS-Driver, innere
* View animiert `transform: translateY` (Slide/Dismiss) im Native-Driver.
*
* Der Inhalt (`children`) wird in einem `flex:1`-Wrapper unter dem Header
* gerendert der Caller layoutet selbst (z.B. `flex:1`-ScrollView + Bottom-Bar
* für eine Input-Zeile, die dann automatisch über der Tastatur sitzt).
*
* Für progressive Mehr-Feld-Formulare (Mail-Account, Domain hinzufügen) kommt
* `<SheetFieldStack>` als Inhalt rein (Phase 2).
*/
const MAX_HEIGHT_PCT = 0.75; // harter Cap — nie höher
const DRAG_FLICK_VELOCITY = 1.5;
const DEFAULT_NAV_HEADER_OFFSET = 56;
// Grabber (8 + 5 + 6) + Header (4 + ~20 + 12 + 1) = ~56. Etwas Puffer.
const CHROME_HEIGHT = 60;
export interface FormSheetProps {
visible: boolean;
@ -49,18 +50,26 @@ export interface FormSheetProps {
/** Titel links im Header. */
title: string;
children: ReactNode;
/** Start-Höhe als Anteil der Screen-Höhe (0..0.75). Default 0.5. */
/**
* Wenn gesetzt Legacy-Fixed-Pct-Mode (alter `<View flex:1>` children-wrap).
* Ohne diesen Prop läuft Auto-Fit: Sheet wächst genau auf Content-Höhe.
*/
initialHeightPct?: number;
/** Drag-down unter diesen Anteil (oder Flick) → dismiss. Default 0.3. */
/** Drag-down unter diesen Anteil (oder Flick) → dismiss. Default 0.25. */
minHeightPct?: number;
/** Backdrop-Deckkraft (0 = kein Dim). Default 0.12 — Main-Screen bleibt sichtbar. */
/**
* Pixel-Offset unter dem Status-Bar, bei dem das Sheet aufhört zu wachsen.
* Default 56 Standard-App-Nav-Header (Material/iOS). 0 = darf bis zur
* Status-Bar gehen.
*/
navHeaderOffset?: number;
/** Backdrop-Deckkraft (0 = kein Dim). Default 0.12. */
backdropOpacity?: number;
/** Default true — Tap auf Backdrop schließt das Sheet. */
dismissOnBackdrop?: boolean;
/** Default true — fügt unten einen Safe-Area-Spacer ein wenn die Tastatur zu ist. */
safeAreaBottom?: boolean;
/** Default true Sheet wächst/expandiert wenn die Tastatur aufgeht. Für
* Sheets ohne Input egal; auf false setzen wenn man's bewusst nicht will. */
/** Default true — Sheet wächst mit der Tastatur (Inputs bleiben sichtbar). */
growWithKeyboard?: boolean;
/** Border-Radius oben. Default 24. */
topRadius?: number;
@ -71,8 +80,9 @@ export function FormSheet({
onClose,
title,
children,
initialHeightPct = 0.5,
minHeightPct = 0.3,
initialHeightPct,
minHeightPct = 0.25,
navHeaderOffset = DEFAULT_NAV_HEADER_OFFSET,
backdropOpacity = 0.12,
dismissOnBackdrop = true,
safeAreaBottom = true,
@ -81,68 +91,98 @@ export function FormSheet({
}: FormSheetProps) {
const colors = useColors();
const insets = useSafeAreaInsets();
// useWindowDimensions: live — auf Android schrumpft height bei offener Tastatur
// (adjustResize), daher dynamisch statt Dimensions.get (statisch beim Modul-Load).
const { height: SCREEN_H } = useWindowDimensions();
// Dimensions.get('screen') = physische Screen-Höhe, statisch, ignoriert
// Keyboard-Resize auf Android. useWindowDimensions würde live schrumpfen
// wenn Keyboard auf und Activity adjustResize macht → maxHeight kollabiert →
// Sheet kann nicht über den Keyboard-Bereich wachsen.
const SCREEN_H = Dimensions.get('screen').height;
const autoMode = initialHeightPct === undefined;
// Cap: nicht über den App-Header. 200px Mindest-Cap als Fallback.
const maxHeight = Math.max(200, SCREEN_H - insets.top - navHeaderOffset);
// Startwert: Auto → kleiner Platzhalter bis onContentSizeChange misst.
// Legacy → der vom Caller gesetzte Pct-Wert.
const fallbackInitial = autoMode
? Math.min(SCREEN_H * 0.35, maxHeight)
: Math.min(SCREEN_H * (initialHeightPct ?? 0.5), maxHeight);
const maxHeight = SCREEN_H * MAX_HEIGHT_PCT;
const initialHeight = Math.min(SCREEN_H * initialHeightPct, maxHeight);
const dismissHeight = SCREEN_H * minHeightPct;
const sheetHeight = useRef(new Animated.Value(initialHeight)).current; // JS driver
const sheetHeight = useRef(new Animated.Value(fallbackInitial)).current; // JS driver
const dismissY = useRef(new Animated.Value(0)).current; // native driver
const currentHeight = useRef(initialHeight); // letzte „Ruhe"-Höhe (Drag oder initial)
const currentHeight = useRef(fallbackInitial); // letzte „Ruhe"-Höhe
const keyboardHeightRef = useRef(0);
const userDraggedRef = useRef(false); // sobald user manuell zieht, kein Auto-Re-Fit mehr
const [keyboardHeight, setKeyboardHeight] = useState(0);
// Reset bei (Wieder-)Öffnen
useEffect(() => {
if (visible) {
sheetHeight.setValue(initialHeight);
// keyboardHeight reset: applyKeyboardHeight hat `if (!visible) return`,
// also kommt das `h=0`-Hide-Event beim Schließen NIE durch → ohne reset
// öffnet das Sheet beim 2. Mal mit altem paddingBottom.
setKeyboardHeight(0);
keyboardHeightRef.current = 0;
sheetHeight.setValue(fallbackInitial);
dismissY.setValue(0);
currentHeight.current = initialHeight;
currentHeight.current = fallbackInitial;
userDraggedRef.current = false;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible]);
const handleClose = () => {
Keyboard.dismiss();
sheetHeight.setValue(initialHeight);
sheetHeight.setValue(fallbackInitial);
dismissY.setValue(0);
currentHeight.current = initialHeight;
currentHeight.current = fallbackInitial;
userDraggedRef.current = false;
onClose();
};
// Keyboard: Sheet wächst (gedeckelt) + paddingBottom schiebt Inhalt über die Tastatur
useEffect(() => {
// Auto-Fit: ScrollView meldet seine natürliche Content-Höhe.
const onContentSize = (_w: number, h: number) => {
if (!autoMode || userDraggedRef.current) return;
const safeArea = safeAreaBottom ? insets.bottom : 0;
const target = Math.min(h + CHROME_HEIGHT + safeArea, maxHeight);
if (Math.abs(target - currentHeight.current) < 4) return;
currentHeight.current = target;
Animated.timing(sheetHeight, {
toValue: Math.min(target + keyboardHeightRef.current, maxHeight),
duration: 180,
useNativeDriver: false,
}).start();
};
// Keyboard: react-native-keyboard-controller liefert reliable native frame
// (Modal-aware auf Android — kein adjustResize-Doppel-Compensation-Bug).
// Wir setzen state → Animated.Value für Sheet-Höhe + paddingBottom-Anker.
const applyKeyboardHeight = (h: number) => {
// Hook feuert global — nur reagieren wenn dieses Sheet sichtbar ist,
// sonst rumpelt's mit Keyboard-Events anderer Screens.
if (!visible) return;
keyboardHeightRef.current = h;
setKeyboardHeight(h);
if (!growWithKeyboard) return;
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
const showSub = Keyboard.addListener(showEvent, (e) => {
const h = e.endCoordinates.height;
keyboardHeightRef.current = h;
setKeyboardHeight(h);
Animated.timing(sheetHeight, {
toValue: Math.min(currentHeight.current + h, maxHeight),
duration: Platform.OS === 'ios' ? e.duration ?? 250 : 200,
useNativeDriver: false,
}).start();
});
const hideSub = Keyboard.addListener(hideEvent, (e) => {
keyboardHeightRef.current = 0;
setKeyboardHeight(0);
Animated.timing(sheetHeight, {
toValue: Math.min(currentHeight.current, maxHeight),
duration: Platform.OS === 'ios' ? e?.duration ?? 250 : 200,
useNativeDriver: false,
}).start();
});
return () => {
showSub.remove();
hideSub.remove();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [growWithKeyboard, maxHeight]);
Animated.timing(sheetHeight, {
toValue: Math.min(currentHeight.current + h, maxHeight),
duration: 220,
useNativeDriver: false,
}).start();
};
useKeyboardHandler({
onStart: (e) => {
'worklet';
runOnJS(applyKeyboardHeight)(e.height);
},
onEnd: (e) => {
'worklet';
runOnJS(applyKeyboardHeight)(e.height);
},
});
const panResponder = useRef(
PanResponder.create({
@ -150,8 +190,6 @@ export function FormSheet({
onMoveShouldSetPanResponder: () => true,
onPanResponderTerminationRequest: () => false,
onPanResponderMove: (_, g) => {
// Drag rauf (dy<0) → höher. Mit offener Tastatur rechnen wir vom
// gewachsenen Stand aus.
const base = currentHeight.current + keyboardHeightRef.current;
const next = base - g.dy;
sheetHeight.setValue(Math.max(dismissHeight - 60, Math.min(maxHeight + 16, next)));
@ -177,8 +215,8 @@ export function FormSheet({
friction: 9,
tension: 70,
}).start();
// „Ruhe"-Höhe = ohne Tastatur-Anteil merken
currentHeight.current = Math.max(0, clamped - keyboardHeightRef.current);
userDraggedRef.current = true; // ab jetzt Auto-Re-Fit ignorieren
},
}),
).current;
@ -206,7 +244,9 @@ export function FormSheet({
borderTopLeftRadius: topRadius,
borderTopRightRadius: topRadius,
overflow: 'hidden',
paddingBottom: Platform.OS === 'ios' ? keyboardHeight : 0,
// iOS + Android beide: Modal-Window honoriert keyboard-resize nicht
// zuverlässig, also manuell padden damit Inputs über der Tastatur sitzen.
paddingBottom: keyboardHeight,
transform: [{ translateY: dismissY }],
shadowColor: '#000',
shadowOffset: { width: 0, height: -2 },
@ -214,9 +254,12 @@ export function FormSheet({
shadowRadius: 8,
}}
>
{/* Grabber-Bar (mittig, drag-area) */}
<View {...dragHandlers} style={{ alignItems: 'center', paddingTop: 8, paddingBottom: 6 }}>
<View style={{ width: 36, height: 5, borderRadius: 3, backgroundColor: colors.border }} />
{/* Grabber-Bar (mittig, drag-area) — paddingY für 44pt-Hit-Area */}
<View
{...dragHandlers}
style={{ alignItems: 'center', paddingTop: 14, paddingBottom: 12 }}
>
<View style={{ width: 42, height: 6, borderRadius: 3, backgroundColor: colors.border }} />
</View>
{/* Header: Titel links — keine Buttons. Auch drag-area. */}
@ -236,7 +279,18 @@ export function FormSheet({
</View>
{/* Inhalt */}
<View style={{ flex: 1 }}>{children}</View>
{autoMode ? (
<ScrollView
style={{ flex: 1 }}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
onContentSizeChange={onContentSize}
>
{children}
</ScrollView>
) : (
<View style={{ flex: 1 }}>{children}</View>
)}
{/* Safe-Area-Spacer (nur wenn Tastatur zu) */}
{safeAreaBottom && <View style={{ height: keyboardHeight > 0 ? 0 : insets.bottom }} />}

View File

@ -0,0 +1,62 @@
import { createContext, useContext, useMemo } from 'react';
import { OnlinePresenceContext, useOnlinePresenceNode } from '../hooks/useOnlineUsers';
import { useAuthStore } from '../stores/auth';
import { useLastSeenHeartbeat } from '../hooks/useLastSeenHeartbeat';
import { useFollowing } from '../hooks/useFollowing';
export type PresenceContextExtended = {
onlineUserIds: Set<string>;
isOnline: (userId: string) => boolean;
};
export const PresenceVisibilityContext = createContext<{
presenceVisible: boolean;
setPresenceVisible: (v: boolean) => void;
}>({
presenceVisible: true,
setPresenceVisible: () => {},
});
export function usePresenceVisibility() {
return useContext(PresenceVisibilityContext);
}
type Props = {
children: React.ReactNode;
};
export function OnlinePresenceProvider({ children }: Props) {
const user = useAuthStore((s) => s.user);
const ids = useOnlinePresenceNode(user?.id ?? null);
const following = useFollowing();
useLastSeenHeartbeat(!!user);
// Debug-Log nur bei tatsächlichen state-changes (size geändert) — sonst
// hängt's an jedem Re-Render und spammed Metro.
useMemo(() => {
console.log(
'[presence] state — self=%s, onlineGlobal=%d, following=%d',
user?.id ?? 'none',
ids.size,
following.size,
);
}, [ids.size, following.size, user?.id]);
const ctx = useMemo(
() => ({
onlineUserIds: ids,
isOnline: (userId: string) => {
if (!user?.id || userId === user.id) return false;
return ids.has(userId) && following.has(userId);
},
}),
[ids, following, user?.id],
);
return (
<OnlinePresenceContext.Provider value={ctx}>
{children}
</OnlinePresenceContext.Provider>
);
}

View File

@ -1,16 +1,17 @@
import { memo, useState, useCallback, useRef, useEffect } from 'react';
import { View, Text, Pressable, Image, Animated } from 'react-native';
import { View, Text, Image, Pressable, Animated, TouchableOpacity } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useRouter } from 'expo-router';
import i18n from '../lib/i18n';
import { apiFetch } from '../lib/api';
import { resolveAvatar } from '../lib/resolveAvatar';
import { formatRelativeTime } from '../lib/formatTime';
import { useCommunityStore, type CommunityPost } from '../stores/community';
import { RiveAvatar } from './RiveAvatar';
import { HeroShieldCheck } from './HeroShieldCheck';
import { useColors } from '../lib/theme';
import { UserAvatar } from './UserAvatar';
/**
* Domain-Approval-Posts werden vom Backend in 4 Sprachen parallel via Groq
@ -48,6 +49,7 @@ function PostCardImpl({ post, onCommentPress }: Props) {
const { t } = useTranslation();
const colors = useColors();
const queryClient = useQueryClient();
const router = useRouter();
// Granular selectors — subscribing to the whole store would re-render every
// PostCard whenever any user likes any post (optimisticLikes mutates).
const applyOptimisticLike = useCommunityStore((s) => s.applyOptimisticLike);
@ -131,19 +133,8 @@ function PostCardImpl({ post, onCommentPress }: Props) {
// regular users use the image/initials fallback path.
const isLyraPost = post.isBot && post.botType === 'lyra';
// Avatar: only render Image if author has avatar id; resolveAvatar returns the URL.
// On image-load error or missing avatar id → initials fallback.
const hasAvatar = !!displayAuthor.avatar && !post.isAnonymous && !isLyraPost;
const avatarUrl = hasAvatar ? resolveAvatar(displayAuthor.avatar, displayAuthor.nickname) : '';
const [avatarLoadFailed, setAvatarLoadFailed] = useState(false);
// Reset error-state when post (or its avatar) changes — list-virtualization may reuse component.
useEffect(() => {
setAvatarLoadFailed(false);
}, [avatarUrl]);
const showAvatarImage = hasAvatar && !avatarLoadFailed;
const avatarInitials = (
authorLabel.charAt(0) + (authorLabel.charAt(1) ?? '')
).toUpperCase() || '?';
const avatarUserId = !post.isAnonymous && !isLyraPost ? displayAuthor.id ?? null : null;
const avatarId = !post.isAnonymous && !isLyraPost ? displayAuthor.avatar ?? null : null;
// domain_approved: extract domain name from Google favicon URL stored in imageUrl
const approvedDomain = (() => {
@ -240,24 +231,26 @@ function PostCardImpl({ post, onCommentPress }: Props) {
{/* Author + Meta */}
<View className="flex-row items-start justify-between mb-2">
<View className="flex-row items-center gap-2.5 flex-1">
{isLyraPost ? (
// Lyra bot posts use the animated Rive avatar at sm (40px).
// The RiveAvatar sm-variant has no border/shadow by design — fits tight in list.
<RiveAvatar emotion="idle" size="sm" />
) : showAvatarImage ? (
<Image
source={{ uri: avatarUrl }}
onError={() => setAvatarLoadFailed(true)}
className="w-10 h-10 rounded-full bg-neutral-100"
/>
) : (
<View className="w-10 h-10 rounded-full bg-rebreak-500 items-center justify-center">
<Text className="text-white text-xs" style={{ fontFamily: 'Nunito_700Bold' }}>
{avatarInitials}
</Text>
</View>
)}
<TouchableOpacity
activeOpacity={!isLyraPost && !post.isAnonymous && !!displayAuthor.id ? 0.7 : 1}
onPress={!isLyraPost && !post.isAnonymous && !!displayAuthor.id
? () => router.push(`/profile/${displayAuthor.id}`)
: undefined}
style={{ flexDirection: 'row', alignItems: 'center', gap: 10, flex: 1 }}
>
<View style={{ position: 'relative' }}>
{isLyraPost ? (
<RiveAvatar emotion="idle" size="sm" />
) : (
<UserAvatar
userId={avatarUserId}
avatar={avatarId}
nickname={authorLabel}
size="md"
isBot={post.isBot}
/>
)}
</View>
<View className="flex-1 min-w-0">
<Text style={{ fontSize: 14, color: colors.text, fontFamily: 'Nunito_600SemiBold' }} numberOfLines={1}>
{authorLabel}
@ -266,7 +259,7 @@ function PostCardImpl({ post, onCommentPress }: Props) {
<Text style={{ fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>{authorDescription}</Text>
)}
</View>
</View>
</TouchableOpacity>
<Text style={{ fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular', flexShrink: 0, marginLeft: 8, marginTop: 2 }}>
{formatRelativeTime(post.createdAt)}
</Text>
@ -446,6 +439,7 @@ function DomainFavicon({ domain, size }: DomainFaviconProps) {
<Image
source={{ uri }}
style={{ width: size, height: size, borderRadius: 6 }}
resizeMode="cover"
onError={() => setFailed(true)}
/>
);

View File

@ -7,22 +7,22 @@ import {
TextInput,
TouchableOpacity,
Keyboard,
Platform,
ActivityIndicator,
Animated,
Image,
Dimensions,
PanResponder,
useWindowDimensions,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useKeyboardHandler } from 'react-native-keyboard-controller';
import { runOnJS } from 'react-native-reanimated';
import { Ionicons } from '@expo/vector-icons';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { apiFetch } from '../lib/api';
import { formatRelativeTime } from '../lib/formatTime';
import { resolveAvatar } from '../lib/resolveAvatar';
import { useColors } from '../lib/theme';
import type { CommunityComment } from '../stores/community';
import { UserAvatar } from './UserAvatar';
const EMOJIS = ['❤️', '🙌', '🔥', '👏', '😢', '😍', '😮', '😂'];
@ -43,11 +43,11 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
const [replyTarget, setReplyTarget] = useState<{ id: string; nickname: string } | null>(null);
const [keyboardHeight, setKeyboardHeight] = useState(0);
// useWindowDimensions: live-tracking. Auf Android schrumpft `height` wenn die
// Tastatur aufgeht (windowSoftInputMode=adjustResize) — daher dynamisch statt
// `Dimensions.get` (statisch beim Modul-Load).
const { height: SCREEN_HEIGHT } = useWindowDimensions();
const MAX_HEIGHT = SCREEN_HEIGHT * 0.75;
// Dimensions.get('screen') = physische Screen-Höhe, statisch, ignoriert
// Keyboard-Resize. MAX bis unter App-Nav-Header (~56dp) damit User per Drag
// bis ganz oben ziehen kann (User-Feedback: "wie alle andere sheets").
const SCREEN_HEIGHT = Dimensions.get('screen').height;
const MAX_HEIGHT = Math.max(300, SCREEN_HEIGHT - insets.top - 56);
const MIN_HEIGHT = SCREEN_HEIGHT * 0.35;
const INITIAL_HEIGHT = SCREEN_HEIGHT * 0.65;
@ -80,6 +80,10 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
useEffect(() => {
if (visible) {
// State-Reset bei Re-Open. Wichtig: keyboardHeight zurücksetzen, weil
// applyKbdHeight wegen `if (!visible) return` das `h=0`-Hide-Event nicht
// verarbeitet hat → sonst öffnet Sheet beim 2. Mal mit altem paddingBottom.
setKeyboardHeight(0);
sheetHeight.setValue(INITIAL_HEIGHT);
dismissY.setValue(0);
currentHeight.current = INITIAL_HEIGHT;
@ -137,34 +141,32 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
}),
).current;
useEffect(() => {
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
const showSub = Keyboard.addListener(showEvent, (e) => {
const h = e.endCoordinates.height;
setKeyboardHeight(h);
const expanded = Math.min(currentHeight.current + h, maxHeightRef.current);
Animated.spring(sheetHeight, {
toValue: expanded,
useNativeDriver: false,
friction: 9,
tension: 70,
}).start();
});
const hideSub = Keyboard.addListener(hideEvent, () => {
setKeyboardHeight(0);
Animated.spring(sheetHeight, {
toValue: currentHeight.current,
useNativeDriver: false,
friction: 9,
tension: 70,
}).start();
});
return () => {
showSub.remove();
hideSub.remove();
};
}, [sheetHeight]);
// keyboard-controller: Modal-aware Frame-Werte für iOS+Android (siehe Memory
// feedback_use_keyboard_controller). Manuelles Keyboard.addListener war auf
// Android im Modal unzuverlässig (paddingBottom=0 → Input hinter Tastatur).
const applyKbdHeight = (h: number) => {
if (!visible) return;
setKeyboardHeight(h);
const target = h > 0
? Math.min(currentHeight.current + h, maxHeightRef.current)
: currentHeight.current;
Animated.spring(sheetHeight, {
toValue: target,
useNativeDriver: false,
friction: 9,
tension: 70,
}).start();
};
useKeyboardHandler({
onStart: (e) => {
'worklet';
runOnJS(applyKbdHeight)(e.height);
},
onEnd: (e) => {
'worklet';
runOnJS(applyKbdHeight)(e.height);
},
});
const { data: comments = [], isLoading } = useQuery<CommunityComment[]>({
queryKey: ['post-comments', postId],
@ -192,12 +194,14 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
setReplyTarget(null);
queryClient.invalidateQueries({ queryKey: ['post-comments', postId] });
queryClient.invalidateQueries({ queryKey: ['community-posts'] });
// Sheet schließen nach erfolgreicher Comment-Abgabe (User-Feedback).
handleClose();
} catch {
// ignore
} finally {
setSubmitting(false);
}
}, [text, postId, replyTarget, queryClient]);
}, [text, postId, replyTarget, queryClient, handleClose]);
const likeComment = useCallback(
async (comment: CommunityComment) => {
@ -249,7 +253,7 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: 'hidden',
paddingBottom: Platform.OS === 'ios' ? keyboardHeight : 0,
paddingBottom: keyboardHeight,
transform: [{ translateY: dismissY }],
shadowColor: '#000',
shadowOffset: { width: 0, height: -2 },
@ -460,42 +464,16 @@ function CommentRow({ comment, isReply = false, onReply, onLike }: CommentRowPro
onLike();
}, [heartScale, onLike]);
const avatarSize = isReply ? 24 : 32;
const avatarRadius = avatarSize / 2;
const resolvedAvatar = comment.authorAvatar
? resolveAvatar(comment.authorAvatar, comment.authorNickname ?? 'anonym')
: null;
return (
<View style={{ flexDirection: 'row', gap: 12, paddingVertical: 8 }}>
<View
style={{
width: avatarSize,
height: avatarSize,
borderRadius: avatarRadius,
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
marginTop: 2,
overflow: 'hidden',
}}
>
{resolvedAvatar ? (
<Image
source={{ uri: resolvedAvatar }}
style={{ width: avatarSize, height: avatarSize, borderRadius: avatarRadius }}
/>
) : (
<Text
style={{
fontSize: isReply ? 9 : 11,
fontFamily: 'Nunito_700Bold',
color: colors.textMuted,
}}
>
{(comment.authorNickname ?? 'AN').slice(0, 2).toUpperCase()}
</Text>
)}
<View style={{ marginTop: 2 }}>
<UserAvatar
userId={!isReply ? (comment.authorId ?? null) : null}
avatar={comment.authorAvatar ?? null}
nickname={comment.authorNickname ?? 'AN'}
size="sm"
showOnlineIndicator={!isReply}
/>
</View>
<View style={{ flex: 1, minWidth: 0 }}>

View File

@ -0,0 +1,120 @@
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>
);
}

View File

@ -2,8 +2,8 @@ import { useState } from 'react';
import {
View,
Text,
TouchableOpacity,
Image,
TouchableOpacity,
StyleSheet,
Modal,
Platform,
@ -11,9 +11,9 @@ import {
import * as Clipboard from 'expo-clipboard';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { resolveAvatar } from '../../lib/resolveAvatar';
import { useColors } from '../../lib/theme';
import { useThemeStore } from '../../stores/theme';
import { UserAvatar } from '../UserAvatar';
export type ChatMsg = {
id: string;
@ -85,8 +85,6 @@ export function ChatBubble({
const isImageOnly =
!!msg.attachmentUrl && msg.attachmentType === 'image' && !msg.content && !msg.replyTo;
const replyHasAttachment = msg.replyTo?.attachmentType === 'image';
const avatarUrl = resolveAvatar(msg.avatar, msg.nickname ?? '?');
const ownBubbleRadius = {
borderTopLeftRadius: 14,
borderTopRightRadius: isFirstInGroup ? 14 : 4,
@ -121,7 +119,13 @@ export function ChatBubble({
{!msg.isOwn && (
<View style={styles.avatarSlot}>
{isLastInGroup ? (
<Image source={{ uri: avatarUrl }} style={styles.avatar} />
<UserAvatar
userId={msg.userId}
avatar={msg.avatar ?? null}
nickname={msg.nickname ?? '?'}
size="sm"
showOnlineIndicator={false}
/>
) : null}
</View>
)}
@ -347,12 +351,6 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
marginRight: 6,
justifyContent: 'flex-end',
},
avatar: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: colors.surfaceElevated,
},
bubbleCol: {
maxWidth: '76%',
},

View File

@ -0,0 +1,42 @@
import { Text } from 'react-native';
import { useTranslation } from 'react-i18next';
import { useOnlineUsers } from '../../hooks/useOnlineUsers';
import { useLastSeenBatch } from '../../hooks/useLastSeenBatch';
type Props = {
userId: string;
};
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) });
}
export function ChatHeaderStatus({ userId }: Props) {
const { t } = useTranslation();
const { isOnline } = useOnlineUsers();
const lastSeenMap = useLastSeenBatch(isOnline(userId) ? [] : [userId]);
const online = isOnline(userId);
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: '#a3a3a3' }}>
{t('presence.online')}
</Text>
);
}
const lastSeen = lastSeenMap[userId];
if (!lastSeen) return null;
return (
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: '#a3a3a3' }}>
{formatLastSeen(lastSeen, t)}
</Text>
);
}

View File

@ -2,9 +2,9 @@ import { useState, useRef } from 'react';
import {
View,
Text,
Image,
TextInput,
TouchableOpacity,
Image,
StyleSheet,
ActivityIndicator,
Platform,
@ -165,7 +165,7 @@ export function ChatInput({
{attachment && (
<View style={styles.attachBar}>
{attachment.isImage ? (
<Image source={{ uri: attachment.uri }} style={styles.attachImg} />
<Image source={{ uri: attachment.uri }} style={styles.attachImg} resizeMode="cover" />
) : (
<View style={styles.attachFileIcon}>
<Ionicons name="document" size={18} color="#737373" />
@ -211,13 +211,13 @@ export function ChatInput({
disabled={!hasContent || sending || uploading || disabled}
style={[
styles.sendBtn,
{ backgroundColor: hasContent ? '#007AFF' : '#e5e5e5' },
{ backgroundColor: '#007AFF', opacity: hasContent ? 1 : 0.4 },
]}
>
{sending || uploading ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Ionicons name="send" size={16} color={hasContent ? '#fff' : '#a3a3a3'} />
<Ionicons name="arrow-up" size={18} color="#fff" />
)}
</TouchableOpacity>
</View>
@ -240,7 +240,7 @@ function decodeBase64(base64: string): Uint8Array {
function makeStyles(colors: ReturnType<typeof useColors>) {
return StyleSheet.create({
container: {
backgroundColor: colors.surface,
backgroundColor: colors.bg,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.border,
},
@ -301,35 +301,33 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
row: {
flexDirection: 'row',
alignItems: 'flex-end',
paddingHorizontal: 8,
gap: 8,
paddingHorizontal: 12,
paddingTop: 8,
paddingBottom: 8,
},
iconBtn: {
width: 36,
height: 36,
borderRadius: 18,
width: 38,
height: 38,
borderRadius: 19,
alignItems: 'center',
justifyContent: 'center',
marginRight: 4,
},
inputWrap: {
flex: 1,
backgroundColor: colors.bg,
backgroundColor: colors.surfaceElevated,
borderRadius: 22,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.border,
paddingHorizontal: 14,
paddingVertical: 9,
paddingHorizontal: 16,
minHeight: 38,
maxHeight: 120,
justifyContent: 'center',
},
input: {
fontSize: 15,
lineHeight: 20,
fontFamily: 'Nunito_400Regular',
color: colors.text,
paddingVertical: Platform.OS === 'ios' ? 9 : 5,
padding: 0,
},
sendBtn: {
width: 38,
@ -337,7 +335,6 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
borderRadius: 19,
alignItems: 'center',
justifyContent: 'center',
marginLeft: 6,
},
});
}

View File

@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { useWindowDimensions, View } from 'react-native';
import Svg, { Circle, Path, Line, Rect } from 'react-native-svg';
import Svg, { Circle, Path, G } from 'react-native-svg';
import { useColors } from '../../lib/theme';
const TILE = 80;
@ -91,8 +91,8 @@ export function DmChatBackground() {
for (let c = 0; c < cols; c++) {
const offsetX = r % 2 === 0 ? 0 : TILE / 2;
items.push({
x: c * TILE + offsetX,
y: r * TILE,
x: c * TILE + offsetX + TILE / 2,
y: r * TILE + TILE / 2,
type: SEQUENCE[seq % SEQUENCE.length],
rotate: [0, 15, -10, 30, -20, 5, -15][seq % 7],
});
@ -106,16 +106,12 @@ export function DmChatBackground() {
<View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }} pointerEvents="none">
<Svg width={width} height={height}>
{symbols.map((s, i) => (
<Svg
<G
key={i}
x={s.x - 10}
y={s.y - 10}
width={20}
height={20}
viewBox="0 0 20 20"
transform={`translate(${s.x - 10}, ${s.y - 10}) rotate(${s.rotate}, 10, 10)`}
>
<SymbolShape type={s.type} color={patternColor} />
</Svg>
</G>
))}
</Svg>
</View>

View File

@ -1,4 +1,4 @@
import { View, Text, TouchableOpacity, Image, StyleSheet } from 'react-native';
import { View, Text, Image, TouchableOpacity, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { useColors } from '../../lib/theme';
@ -43,7 +43,7 @@ export function RoomCard({ room, onPress }: Props) {
<View style={styles.row}>
<View style={[styles.avatar, { backgroundColor: room.isPublic ? '#EFF6FF' : colors.surfaceElevated }]}>
{room.avatarUrl ? (
<Image source={{ uri: room.avatarUrl }} style={styles.avatarImg} />
<Image source={{ uri: room.avatarUrl }} style={styles.avatarImg} resizeMode="cover" />
) : !room.isPublic ? (
<Text style={styles.avatarInitials}>{initials}</Text>
) : (

View File

@ -5,18 +5,18 @@ import {
ScrollView,
Switch,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import * as WebBrowser from 'expo-web-browser';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { useMailConnect, detectProvider, type MailProvider } from '../../hooks/useMailConnect';
import { useMailConnect, type MailProvider } from '../../hooks/useMailConnect';
import { humanizeMailError } from '../../lib/mailErrors';
import { apiFetch } from '../../lib/api';
import { useColors } from '../../lib/theme';
import { FormSheet } from '../FormSheet';
import { SheetFieldStack } from '../SheetFieldStack';
import { useMailConnectDraft } from '../../stores/mailConnectDraft';
const CONSENT_VERSION = 'art9-mail-v1-2026-05-13';
@ -98,7 +98,7 @@ const PROVIDERS: ProviderConfig[] = [
* Drei Ansichten im selben Sheet (kein Navigations-Header):
* 1. Consent-Screen (Art. 9 DSGVO) MUSS zuerst bestätigt werden
* 2. Provider-Grid (6 Tiles) nach Consent-Bestätigung freigeschaltet
* 3. Formular: Email + App-Passwort als SheetFieldStack
* 3. Formular: Email App-Passwort Bezeichnung (eine ScrollView)
*/
export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) {
const { t } = useTranslation();
@ -124,7 +124,6 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) {
const [password, setPassword] = useState('');
const [passwordVisible, setPasswordVisible] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const [fieldsComplete, setFieldsComplete] = useState(false);
const [oauthRunning, setOauthRunning] = useState(false);
const [oauthError, setOauthError] = useState<string | null>(null);
@ -133,7 +132,6 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) {
setPassword('');
setPasswordVisible(false);
setFormError(null);
setFieldsComplete(false);
setOauthRunning(false);
setOauthError(null);
onClose();
@ -162,7 +160,6 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) {
setPassword('');
setTitle(defaultTitleForProvider(provider));
setFormError(null);
setFieldsComplete(false);
setOauthError(null);
if (provider.authMethod === 'oauth_microsoft') {
setView('oauth_warning');
@ -294,7 +291,10 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) {
visible={visible}
onClose={handleClose}
title={sheetTitle}
initialHeightPct={0.75}
// Fixed 0.85 über ALLE Steps (Consent/Grid/Form/OAuth) — Auto-Fit hatte
// pro Step gehüpft (kleiner Grid + großes Form), schlechtes Multi-Step-UX.
// User kann manuell per Drag-Grabber hoch- oder runterziehen.
initialHeightPct={0.85}
growWithKeyboard
>
{view === 'consent' ? (
@ -318,186 +318,292 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) {
) : view === 'oauth_pending' ? (
<OAuthPendingStep t={t} colors={colors} />
) : (
<SheetFieldStack
fields={[
{
key: 'email',
label: t('mail.form_email_label'),
placeholder: t('mail.form_email_placeholder'),
value: email,
onChangeText: (v) => { setEmail(v); setFormError(null); },
keyboardType: 'email-address',
autoCapitalize: 'none',
autoCorrect: false,
validate: (v) =>
v.trim().length === 0 ? t('mail.form_fields_required') : undefined,
},
{
key: 'title',
label: t('mail.title_label'),
placeholder: t('mail.title_placeholder'),
value: title,
onChangeText: setTitle,
autoCapitalize: 'sentences',
autoCorrect: false,
},
{
key: 'password',
label: t('mail.form_password_label'),
placeholder: t('mail.form_password_placeholder'),
value: password,
onChangeText: (v) => { setPassword(v); setFormError(null); },
secureTextEntry: !passwordVisible,
autoCapitalize: 'none',
autoCorrect: false,
validate: (v) =>
v.trim().length === 0 ? t('mail.form_fields_required') : undefined,
suffix: (
<TouchableOpacity
activeOpacity={0.7}
onPress={() => setPasswordVisible((p) => !p)}
hitSlop={8}
>
<Ionicons
name={passwordVisible ? 'eye-off-outline' : 'eye-outline'}
size={20}
color="#a3a3a3"
/>
</TouchableOpacity>
),
},
]}
intro={
<View style={{ gap: 10 }}>
{/* App-Password-Guide — provider-spezifisch, nicht für 'other' */}
{selectedProvider && selectedProvider.id !== 'other' && (
<View
style={{
flexDirection: 'row',
gap: 10,
padding: 12,
backgroundColor: '#f0f7ff',
borderRadius: 12,
borderWidth: 1,
borderColor: '#bfdbfe',
}}
>
<Ionicons
name="information-circle"
size={18}
color="#1d4ed8"
style={{ marginTop: 1 }}
/>
<View style={{ flex: 1, gap: 4 }}>
<Text
style={{ fontSize: 12, fontFamily: 'Nunito_600SemiBold', color: '#1e3a8a' }}
>
{t('mail.app_password_required_title')}
</Text>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#1d4ed8',
lineHeight: 17,
}}
>
{t(selectedProvider.guideKey)}
</Text>
{selectedProvider.guideUrl.length > 0 && (
<TouchableOpacity
activeOpacity={0.7}
onPress={() => Linking.openURL(selectedProvider.guideUrl)}
>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
color: '#007AFF',
marginTop: 2,
}}
>
{t('mail.app_password_open_link')}
</Text>
</TouchableOpacity>
)}
</View>
</View>
)}
{/* Datenschutz-Zusicherung — immer sichtbar */}
<View
style={{
flexDirection: 'row',
gap: 8,
padding: 12,
backgroundColor: '#f0fdf4',
borderRadius: 12,
borderWidth: 1,
borderColor: '#bbf7d0',
}}
>
<Ionicons name="shield-checkmark" size={16} color="#16a34a" style={{ marginTop: 1 }} />
<Text
style={{
flex: 1,
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#166534',
lineHeight: 17,
}}
>
{t('mail.form_privacy_note')}
</Text>
</View>
</View>
}
onComplete={() => setFieldsComplete(true)}
>
{/* Fehler */}
{(formError ?? (connectError ? t(humanizeMailError(connectError)) : null)) && (
<Text
style={{
fontSize: 13,
fontFamily: 'Nunito_400Regular',
color: '#dc2626',
marginBottom: 10,
}}
>
{formError ?? t(humanizeMailError(connectError))}
</Text>
)}
{/* Connect-Button */}
<TouchableOpacity
activeOpacity={0.85}
onPress={handleConnect}
disabled={connecting}
style={{ marginTop: 4, marginBottom: 12 }}
>
<View
style={{
backgroundColor: connecting ? '#d4d4d4' : '#007AFF',
borderRadius: 14,
paddingVertical: 14,
alignItems: 'center',
}}
>
{connecting ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: '#fff' }}>
{t('mail.form_connect_btn')}
</Text>
)}
</View>
</TouchableOpacity>
</SheetFieldStack>
<FormView
selectedProvider={selectedProvider}
email={email}
setEmail={setEmail}
password={password}
setPassword={setPassword}
passwordVisible={passwordVisible}
setPasswordVisible={setPasswordVisible}
title={title}
setTitle={setTitle}
formError={formError}
setFormError={setFormError}
connectError={connectError}
connecting={connecting}
onConnect={handleConnect}
t={t}
colors={colors}
/>
)}
</FormSheet>
);
}
// ---------------------------------------------------------------------------
// Sub-View: Form (email → password → title, eine ScrollView)
// ---------------------------------------------------------------------------
function FormView({
selectedProvider,
email,
setEmail,
password,
setPassword,
passwordVisible,
setPasswordVisible,
title,
setTitle,
formError,
setFormError,
connectError,
connecting,
onConnect,
t,
colors,
}: {
selectedProvider: { id: string; guideKey: string; guideUrl: string } | null;
email: string;
setEmail: (v: string) => void;
password: string;
setPassword: (v: string) => void;
passwordVisible: boolean;
setPasswordVisible: (v: boolean) => void;
title: string;
setTitle: (v: string) => void;
formError: string | null;
setFormError: (v: string | null) => void;
connectError: string | null;
connecting: boolean;
onConnect: () => void;
t: (key: string) => string;
colors: ReturnType<typeof useColors>;
}) {
const errorText = formError ?? (connectError ? t(humanizeMailError(connectError)) : null);
return (
<View style={{ flex: 1 }}>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{ padding: 16, gap: 16, paddingBottom: 24 }}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{/* App-Password-Banner — provider-spezifisch, nicht für 'other' */}
{selectedProvider && selectedProvider.id !== 'other' && (
<View
style={{
flexDirection: 'row',
gap: 10,
padding: 12,
backgroundColor: '#f0f7ff',
borderRadius: 12,
borderWidth: 1,
borderColor: '#bfdbfe',
}}
>
<Ionicons
name="information-circle"
size={18}
color="#1d4ed8"
style={{ marginTop: 1 }}
/>
<View style={{ flex: 1, gap: 4 }}>
<Text style={{ fontSize: 12, fontFamily: 'Nunito_600SemiBold', color: '#1e3a8a' }}>
{t('mail.app_password_required_title')}
</Text>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#1d4ed8',
lineHeight: 17,
}}
>
{t(selectedProvider.guideKey)}
</Text>
{selectedProvider.guideUrl.length > 0 && (
<TouchableOpacity
activeOpacity={0.7}
onPress={() => Linking.openURL(selectedProvider.guideUrl)}
>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
color: '#007AFF',
marginTop: 2,
}}
>
{t('mail.app_password_open_link')}
</Text>
</TouchableOpacity>
)}
</View>
</View>
)}
{/* E-Mail */}
<View style={{ gap: 6 }}>
<Text style={{ fontSize: 12, fontFamily: 'Nunito_600SemiBold', color: colors.textMuted }}>
{t('mail.form_email_label')}
</Text>
<View
style={{
backgroundColor: colors.surfaceElevated,
borderRadius: 12,
paddingHorizontal: 14,
}}
>
<TextInput
value={email}
onChangeText={(v) => { setEmail(v); setFormError(null); }}
placeholder={t('mail.form_email_placeholder')}
placeholderTextColor={colors.textMuted}
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
style={{
paddingVertical: 12,
fontSize: 15,
fontFamily: 'Nunito_400Regular',
color: colors.text,
}}
/>
</View>
</View>
{/* App-Passwort */}
<View style={{ gap: 6 }}>
<Text style={{ fontSize: 12, fontFamily: 'Nunito_600SemiBold', color: colors.textMuted }}>
{t('mail.form_password_label')}
</Text>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.surfaceElevated,
borderRadius: 12,
paddingHorizontal: 14,
}}
>
<TextInput
value={password}
onChangeText={(v) => { setPassword(v); setFormError(null); }}
placeholder={t('mail.form_password_placeholder')}
placeholderTextColor={colors.textMuted}
secureTextEntry={!passwordVisible}
autoCapitalize="none"
autoCorrect={false}
style={{
flex: 1,
paddingVertical: 12,
fontSize: 15,
fontFamily: 'Nunito_400Regular',
color: colors.text,
}}
/>
<TouchableOpacity
activeOpacity={0.7}
onPress={() => setPasswordVisible(!passwordVisible)}
hitSlop={8}
>
<Ionicons
name={passwordVisible ? 'eye-off-outline' : 'eye-outline'}
size={20}
color="#a3a3a3"
/>
</TouchableOpacity>
</View>
{/* AES-Verschlüsselungs-Hinweis als Footnote */}
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 2 }}>
<Ionicons name="shield-checkmark" size={11} color="#16a34a" />
<Text
style={{
fontSize: 11,
fontFamily: 'Nunito_400Regular',
color: colors.textMuted,
flex: 1,
lineHeight: 15,
}}
>
{t('mail.form_privacy_note')}
</Text>
</View>
</View>
{/* Bezeichnung */}
<View style={{ gap: 6 }}>
<Text style={{ fontSize: 12, fontFamily: 'Nunito_600SemiBold', color: colors.textMuted }}>
{t('mail.title_label')}
</Text>
<View
style={{
backgroundColor: colors.surfaceElevated,
borderRadius: 12,
paddingHorizontal: 14,
}}
>
<TextInput
value={title}
onChangeText={setTitle}
placeholder={t('mail.title_placeholder')}
placeholderTextColor={colors.textMuted}
autoCapitalize="sentences"
autoCorrect={false}
style={{
paddingVertical: 12,
fontSize: 15,
fontFamily: 'Nunito_400Regular',
color: colors.text,
}}
/>
</View>
</View>
{/* Fehler */}
{errorText && (
<Text
style={{
fontSize: 13,
fontFamily: 'Nunito_400Regular',
color: '#dc2626',
}}
>
{errorText}
</Text>
)}
{/* Connect-Button */}
<TouchableOpacity
activeOpacity={0.85}
onPress={onConnect}
disabled={connecting}
style={{ marginTop: 4, marginBottom: 12 }}
>
<View
style={{
backgroundColor: connecting ? '#d4d4d4' : '#007AFF',
borderRadius: 14,
paddingVertical: 14,
alignItems: 'center',
}}
>
{connecting ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: '#fff' }}>
{t('mail.form_connect_btn')}
</Text>
)}
</View>
</TouchableOpacity>
</ScrollView>
</View>
);
}
// ---------------------------------------------------------------------------
// Sub-View: Consent (Art. 9 DSGVO) — muss als erster Schritt bestätigt werden
// ---------------------------------------------------------------------------
@ -519,6 +625,7 @@ function ConsentStep({
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{ padding: 20, gap: 16 }}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<Text
@ -657,6 +764,7 @@ function OAuthWarningStep({
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{ padding: 20, gap: 16 }}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<View
@ -803,6 +911,7 @@ function ProviderGrid({
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{ padding: 20, gap: 12 }}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<Text

View File

@ -0,0 +1,17 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '../lib/api';
export function useFollowing(): Set<string> {
const { data, error } = useQuery({
queryKey: ['me-following'],
queryFn: async () => {
const r = await apiFetch<{ userIds: string[] }>('/api/me/following');
console.log('[presence] /api/me/following →', r.userIds?.length ?? 0, 'IDs:', r.userIds);
return r;
},
staleTime: 5 * 60_000,
});
if (error) console.warn('[presence] /api/me/following ERROR:', error);
return useMemo(() => new Set(data?.userIds ?? []), [data]);
}

View File

@ -0,0 +1,19 @@
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '../lib/api';
type LastSeenMap = Record<string, string | null>;
export function useLastSeenBatch(userIds: string[]): LastSeenMap {
const sorted = [...userIds].sort();
const joinedKey = sorted.join(',');
const { data } = useQuery<LastSeenMap>({
queryKey: ['last-seen', joinedKey],
queryFn: () =>
apiFetch<LastSeenMap>(`/api/presence/last-seen?userIds=${encodeURIComponent(joinedKey)}`),
enabled: sorted.length > 0,
staleTime: 30_000,
});
return data ?? {};
}

View File

@ -0,0 +1,36 @@
import { useEffect } from 'react';
import { AppState, type AppStateStatus } from 'react-native';
import { apiFetch } from '../lib/api';
const ping = (reason: string) => {
apiFetch('/api/me/last-seen', { method: 'POST' })
.then((r) => console.log('[presence] heartbeat OK (' + reason + ')', r))
.catch((e) => console.warn('[presence] heartbeat FAIL (' + reason + '):', e?.message ?? e));
};
export function useLastSeenHeartbeat(enabled: boolean) {
useEffect(() => {
if (!enabled) return;
// 60s-Interval während App im Foreground
const interval = setInterval(() => {
if (AppState.currentState === 'active') {
ping('interval');
}
}, 60_000);
// Phase-2-Alternative zur Edge-Function: bei App-Background sofort einen
// finalen Ping → lastSeenAt ist exakt der Schließ-Zeitpunkt, kein 60s-Lag
// mehr für den letzten-aktiv-Zustand. 90% Edge-Function-Gewinn ohne Setup.
const sub = AppState.addEventListener('change', (next: AppStateStatus) => {
if (next === 'background' || next === 'inactive') {
ping('background');
}
});
return () => {
clearInterval(interval);
sub.remove();
};
}, [enabled]);
}

View File

@ -38,6 +38,7 @@ export type Me = {
lyraVoiceId: string | null;
onboardingStep: OnboardingStep;
created_at?: string;
presenceVisible?: boolean;
};
let cachedMe: Me | null = null;

View File

@ -0,0 +1,100 @@
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;
}

View File

@ -931,6 +931,7 @@
"file_attachment": "ملف",
"upload_failed": "فشل الرفع",
"member_count": "%{n} أعضاء",
"member_count_online": "%{n} أعضاء · %{online} متصل",
"pending_request": "طلبات الانضمام",
"approve": "قبول",
"reject": "رفض",
@ -1030,7 +1031,10 @@
"hour_evening": "مساءً",
"hour_night": "ليلاً"
}
}
},
"privacy_section_title": "الخصوصية",
"show_online_status": "إظهار حالة الاتصال",
"show_online_status_hint": "فقط الأشخاص الذين تتابعهم يرون متى تكون متصلاً"
},
"demographics": {
"employment_status_employed": "موظف",
@ -1263,5 +1267,12 @@
"crisis_emergency_desc": "إذا كنت أنت أو شخص بالقرب منك في خطر فوري اتصل فوراً بالطوارئ.",
"crisis_emergency_cta": "112 — الطوارئ",
"crisis_disclaimer": "هذه الجهات مستقلة عن rebreak. نحيلك إليها ولكننا لا نُقدّم الإرشاد بأنفسنا."
},
"presence": {
"online": "متصل",
"just_now": "الآن",
"minutes_ago": "منذ %{minutes} دقيقة",
"hours_ago": "منذ %{hours} ساعة",
"days_ago": "منذ %{days} يوم"
}
}

View File

@ -931,6 +931,7 @@
"file_attachment": "Datei",
"upload_failed": "Upload fehlgeschlagen",
"member_count": "%{n} Mitglieder",
"member_count_online": "%{n} Mitglieder · %{online} online",
"pending_request": "Beitrittsanfragen",
"approve": "Annehmen",
"reject": "Ablehnen",
@ -979,7 +980,8 @@
"vote_no": "Nein",
"vote_rejected": "Abgelehnt",
"vote_in_review": "In Prüfung",
"voted_thanks": "Danke für deine Stimme!"
"voted_thanks": "Danke für deine Stimme!",
"recent_posts": "LETZTE POSTS"
},
"streak": {
"label_one": "Tag",
@ -1030,7 +1032,10 @@
"hour_evening": "Abend",
"hour_night": "Nacht"
}
}
},
"privacy_section_title": "Privatsphäre",
"show_online_status": "Online-Status anzeigen",
"show_online_status_hint": "Nur Personen, denen du folgst, sehen wenn du online bist"
},
"demographics": {
"employment_status_employed": "angestellt",
@ -1264,6 +1269,13 @@
"crisis_emergency_cta": "112 — Notruf",
"crisis_disclaimer": "Diese Stellen sind unabhängig von Rebreak. Wir verweisen weiter, beraten aber nicht selbst."
},
"presence": {
"online": "Online",
"just_now": "gerade eben",
"minutes_ago": "vor %{minutes} Min.",
"hours_ago": "vor %{hours} Std.",
"days_ago": "vor %{days} T."
},
"lyra_posts": {
"motivation_quiet_01": "Manchmal ist ein Tag, an dem man einfach nicht gespielt hat, schon ein stiller Sieg. Kein Applaus nötig — du weißt, was du heute getan hast.",
"motivation_quiet_02": "Der Drang kommt in Wellen. Er fühlt sich endlos an — ist er aber nicht. Die meisten Wellen dauern unter 20 Minuten. Einfach warten.",

View File

@ -931,6 +931,7 @@
"file_attachment": "File",
"upload_failed": "Upload failed",
"member_count": "%{n} members",
"member_count_online": "%{n} members · %{online} online",
"pending_request": "Join requests",
"approve": "Approve",
"reject": "Reject",
@ -979,7 +980,8 @@
"vote_no": "No",
"vote_rejected": "Rejected",
"vote_in_review": "Under review",
"voted_thanks": "Thanks for your vote!"
"voted_thanks": "Thanks for your vote!",
"recent_posts": "RECENT POSTS"
},
"streak": {
"label_one": "day",
@ -1030,7 +1032,10 @@
"hour_evening": "Evening",
"hour_night": "Night"
}
}
},
"privacy_section_title": "Privacy",
"show_online_status": "Show online status",
"show_online_status_hint": "Only people you follow see when you're online"
},
"demographics": {
"employment_status_employed": "employed",
@ -1263,5 +1268,12 @@
"crisis_emergency_desc": "If you or someone nearby is in immediate danger, call emergency services immediately.",
"crisis_emergency_cta": "112 — Emergency",
"crisis_disclaimer": "These services are independent of Rebreak. We refer you onward but do not offer counselling ourselves."
},
"presence": {
"online": "Online",
"just_now": "just now",
"minutes_ago": "%{minutes} min ago",
"hours_ago": "%{hours} h ago",
"days_ago": "%{days} d ago"
}
}

View File

@ -918,6 +918,7 @@
"file_attachment": "Fichier",
"upload_failed": "Échec du téléversement",
"member_count": "%{n} membres",
"member_count_online": "%{n} membres · %{online} en ligne",
"pending_request": "Demandes d'adhésion",
"approve": "Accepter",
"reject": "Refuser",
@ -1017,7 +1018,10 @@
"hour_evening": "Soir",
"hour_night": "Nuit"
}
}
},
"privacy_section_title": "Confidentialité",
"show_online_status": "Afficher le statut en ligne",
"show_online_status_hint": "Seules les personnes que vous suivez voient si vous êtes en ligne"
},
"demographics": {
"employment_status_employed": "salarié",
@ -1247,5 +1251,12 @@
"crisis_emergency_desc": "Si vous ou quelqu'un près de vous est en danger immédiat, appelez immédiatement les secours.",
"crisis_emergency_cta": "112 — Urgences",
"crisis_disclaimer": "Ces services sont indépendants de Rebreak. Nous vous orientons mais n'assurons pas de conseil nous-mêmes."
},
"presence": {
"online": "En ligne",
"just_now": "à l'instant",
"minutes_ago": "il y a %{minutes} min",
"hours_ago": "il y a %{hours} h",
"days_ago": "il y a %{days} j"
}
}

View File

@ -1,5 +1,5 @@
{
"name": "@trucko/rebreak-native",
"name": "rebreak-native",
"version": "0.3.0",
"private": true,
"main": "expo-router/entry",
@ -36,7 +36,7 @@
"expo-file-system": "~19.0.22",
"expo-font": "~14.0.11",
"expo-haptics": "^15.0.8",
"expo-image-manipulator": "~14.0.7",
"expo-image-manipulator": "~14.0.7",
"expo-image-picker": "~17.0.11",
"expo-linking": "~8.0.12",
"expo-local-authentication": "~17.0.8",