diff --git a/apps/rebreak-native/app/(app)/chat.tsx b/apps/rebreak-native/app/(app)/chat.tsx
index c9c1c4f..65c35d8 100644
--- a/apps/rebreak-native/app/(app)/chat.tsx
+++ b/apps/rebreak-native/app/(app)/chat.tsx
@@ -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 (
-
- {!avatarLoadFailed ? (
- setAvatarLoadFailed(true)}
- />
- ) : (
- {avatarInitials}
- )}
-
+
@@ -229,6 +218,7 @@ function makeStyles(colors: ReturnType) {
dmRow: {
flexDirection: 'row',
alignItems: 'center',
+ gap: 12,
paddingHorizontal: 16,
paddingVertical: 12,
backgroundColor: colors.bg,
diff --git a/apps/rebreak-native/app/_layout.tsx b/apps/rebreak-native/app/_layout.tsx
index 6768932..afa4a5b 100644
--- a/apps/rebreak-native/app/_layout.tsx
+++ b/apps/rebreak-native/app/_layout.tsx
@@ -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 (
+
@@ -200,6 +203,7 @@ function RootLayoutInner() {
/>
+
);
}
diff --git a/apps/rebreak-native/app/dm.tsx b/apps/rebreak-native/app/dm.tsx
index b4c20de..e814816 100644
--- a/apps/rebreak-native/app/dm.tsx
+++ b/apps/rebreak-native/app/dm.tsx
@@ -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([]);
const [partner, setPartner] = useState(null);
const partnerRef = useRef(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() {
-
- {partner?.avatar ? (
-
- ) : (
-
- {(partner?.nickname ?? '?').slice(0, 2).toUpperCase()}
-
- )}
+
+
+
+
+
+ {partner?.nickname ?? '…'}
+
+ {userId && }
-
- {partner?.nickname ?? '…'}
-
@@ -312,7 +327,7 @@ export default function DmScreen() {
)}
-
+ 0 ? 8 : Math.max(12, insets.bottom), backgroundColor: colors.bg }}>
) {
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) {
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',
diff --git a/apps/rebreak-native/app/profile/[userId].tsx b/apps/rebreak-native/app/profile/[userId].tsx
index f844448..30e5fd6 100644
--- a/apps/rebreak-native/app/profile/[userId].tsx
+++ b/apps/rebreak-native/app/profile/[userId].tsx
@@ -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 = {
- free: 'Free',
- pro: 'Pro',
- legend: 'Legend',
-};
-
-const planColors: Record = {
- 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) {
{value}
-
+
{label}
@@ -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(null);
+ const [followPending, setFollowPending] = useState(false);
+ const [activeCommentsPostId, setActiveCommentsPostId] = useState(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({
+ queryKey: ['foreign-profile', userId],
+ queryFn: () => apiFetch(`/api/social/profile/${userId}`),
+ enabled: !!userId,
+ });
+
+ const { data: userPosts = [], isLoading: postsLoading } = useQuery({
+ queryKey: ['community-posts', { userId }],
+ queryFn: () => apiFetch(`/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 (
+
+
+
+ router.back()} hitSlop={8} activeOpacity={0.5} style={{ padding: 8 }}>
+
+
+
+
+
+
+
+
+ );
+ }
+
+ if (isError || !profile) {
+ return (
+
+
+
+ router.back()} hitSlop={8} activeOpacity={0.5} style={{ padding: 8 }}>
+
+
+
+
+
+
+ {t('common.unknown_error')}
+
+ router.back()} activeOpacity={0.7}>
+
+ {t('common.back')}
+
+
+
+
+ );
+ }
+
+ const displayFollowers = localFollowersCount ?? profile.followersCount;
return (
@@ -119,12 +201,7 @@ export default function ForeignProfileScreen() {
paddingHorizontal: 12,
}}
>
- router.back()}
- hitSlop={8}
- activeOpacity={0.5}
- style={{ padding: 8 }}
- >
+ router.back()} hitSlop={8} activeOpacity={0.5} style={{ padding: 8 }}>
@@ -140,76 +217,27 @@ export default function ForeignProfileScreen() {
showsVerticalScrollIndicator={false}
>
-
- {showImage ? (
- setImageFailed(true)}
- style={{ width: 92, height: 92, borderRadius: 46 }}
- />
- ) : (
-
- {initials}
-
- )}
+
+
-
+
{profile.nickname}
-
-
-
- {planLabel[profile.plan].toUpperCase()}
-
-
-
- Mitglied seit {profile.memberSince}
-
-
+
+ Mitglied seit {formatJoinedAt(profile.joinedAt)}
+
{
- // 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,
}}>
-
+
{isFollowing ? 'Folge ich' : 'Folgen'}
{
- // 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',
}}>
-
- Nachricht
-
+
+ Nachricht
+
-
+
-
+
- {/* TODO: GET /api/community/posts?userId=... — letzte 5 Posts */}
- LETZTE POSTS
+ {t('community.recent_posts')}
-
-
+
+
+
+
+ ) : userPosts.length === 0 ? (
+
- Posts-Liste folgt in Phase C
-
-
+
+ {t('community.no_posts')}
+
+
+ ) : (
+ userPosts.map((post) => (
+
+ ))
+ )}
+
+
);
}
diff --git a/apps/rebreak-native/app/profile/index.tsx b/apps/rebreak-native/app/profile/index.tsx
index 3acb015..8d857a6 100644
--- a/apps/rebreak-native/app/profile/index.tsx
+++ b/apps/rebreak-native/app/profile/index.tsx
@@ -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(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() {
+
+
+ {t('profile.privacy_section_title').toUpperCase()}
+
+
+
+
+ {t('profile.show_online_status')}
+
+
+ {t('profile.show_online_status_hint')}
+
+
+
+
+
+
diff --git a/apps/rebreak-native/app/room.tsx b/apps/rebreak-native/app/room.tsx
index 6eaba2e..efa99f7 100644
--- a/apps/rebreak-native/app/room.tsx
+++ b/apps/rebreak-native/app/room.tsx
@@ -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(null);
const [myUserId, setMyUserId] = useState();
+ const { isOnline } = useOnlineUsers();
const { roomId } = useLocalSearchParams<{ roomId: string }>();
+ const [keyboardHeight, setKeyboardHeight] = useState(0);
const [messages, setMessages] = useState([]);
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({
queryKey: ['chat-room', roomId],
queryFn: async () => {
@@ -306,7 +322,7 @@ export default function RoomScreen() {
{room?.avatarUrl ? (
-
+
) : (
{initials}
)}
@@ -315,11 +331,16 @@ export default function RoomScreen() {
{room?.name ?? '…'}
- {room && (
-
- {t('chat.member_count', { n: room.memberCount })}
-
- )}
+ {room && (() => {
+ const onlineCount = members.filter((m) => isOnline(m.userId)).length;
+ return (
+
+ {onlineCount > 0
+ ? t('chat.member_count_online', { n: room.memberCount, online: onlineCount })
+ : t('chat.member_count', { n: room.memberCount })}
+
+ );
+ })()}
setSettingsOpen(true)} hitSlop={8} activeOpacity={0.7}>
@@ -360,7 +381,8 @@ export default function RoomScreen() {
) : (
flatRef.current?.scrollToEnd({ animated: false })}
/>
-
+ 0 ? 8 : Math.max(12, insets.bottom), backgroundColor: colors.bg }}>
{room.avatarUrl ? (
-
+
) : (
@@ -595,14 +617,13 @@ function RoomSettingsModal({
{members.map((m) => (
-
- {m.avatar ? (
-
- ) : (
-
- {m.nickname.slice(0, 2).toUpperCase()}
-
- )}
+
+
{m.nickname}
@@ -658,10 +679,7 @@ function makeStyles(colors: ReturnType) {
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) {
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: {
diff --git a/apps/rebreak-native/components/FormSheet.tsx b/apps/rebreak-native/components/FormSheet.tsx
index 8f3cfdb..8265bb9 100644
--- a/apps/rebreak-native/components/FormSheet.tsx
+++ b/apps/rebreak-native/components/FormSheet.tsx
@@ -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:
- * - `` 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 `` 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
- * `` 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 `` 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) */}
-
-
+ {/* Grabber-Bar (mittig, drag-area) — paddingY für 44pt-Hit-Area */}
+
+
{/* Header: Titel links — keine Buttons. Auch drag-area. */}
@@ -236,7 +279,18 @@ export function FormSheet({
{/* Inhalt */}
- {children}
+ {autoMode ? (
+
+ {children}
+
+ ) : (
+ {children}
+ )}
{/* Safe-Area-Spacer (nur wenn Tastatur zu) */}
{safeAreaBottom && 0 ? 0 : insets.bottom }} />}
diff --git a/apps/rebreak-native/components/OnlinePresenceProvider.tsx b/apps/rebreak-native/components/OnlinePresenceProvider.tsx
new file mode 100644
index 0000000..c12b91c
--- /dev/null
+++ b/apps/rebreak-native/components/OnlinePresenceProvider.tsx
@@ -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;
+ 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 (
+
+ {children}
+
+ );
+}
diff --git a/apps/rebreak-native/components/PostCard.tsx b/apps/rebreak-native/components/PostCard.tsx
index 342c4ba..685466f 100644
--- a/apps/rebreak-native/components/PostCard.tsx
+++ b/apps/rebreak-native/components/PostCard.tsx
@@ -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 */}
-
- {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.
-
- ) : showAvatarImage ? (
- setAvatarLoadFailed(true)}
- className="w-10 h-10 rounded-full bg-neutral-100"
- />
- ) : (
-
-
- {avatarInitials}
-
-
- )}
+ router.push(`/profile/${displayAuthor.id}`)
+ : undefined}
+ style={{ flexDirection: 'row', alignItems: 'center', gap: 10, flex: 1 }}
+ >
+
+ {isLyraPost ? (
+
+ ) : (
+
+ )}
+
{authorLabel}
@@ -266,7 +259,7 @@ function PostCardImpl({ post, onCommentPress }: Props) {
{authorDescription}
)}
-
+
{formatRelativeTime(post.createdAt)}
@@ -446,6 +439,7 @@ function DomainFavicon({ domain, size }: DomainFaviconProps) {
setFailed(true)}
/>
);
diff --git a/apps/rebreak-native/components/PostCommentsSheet.tsx b/apps/rebreak-native/components/PostCommentsSheet.tsx
index 90175f7..49e3dd6 100644
--- a/apps/rebreak-native/components/PostCommentsSheet.tsx
+++ b/apps/rebreak-native/components/PostCommentsSheet.tsx
@@ -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({
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 (
-
- {resolvedAvatar ? (
-
- ) : (
-
- {(comment.authorNickname ?? 'AN').slice(0, 2).toUpperCase()}
-
- )}
+
+
diff --git a/apps/rebreak-native/components/UserAvatar.tsx b/apps/rebreak-native/components/UserAvatar.tsx
new file mode 100644
index 0000000..eb2dc15
--- /dev/null
+++ b/apps/rebreak-native/components/UserAvatar.tsx
@@ -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 (
+
+ );
+}
+
+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 (
+
+ {hasImage ? (
+ setImageFailed(true)}
+ style={{
+ width: s.avatar,
+ height: s.avatar,
+ borderRadius: radius,
+ backgroundColor: colors.surfaceElevated,
+ }}
+ resizeMode="cover"
+ />
+ ) : (
+
+
+ {initials}
+
+
+ )}
+
+ {showDot && }
+
+ );
+}
diff --git a/apps/rebreak-native/components/chat/ChatBubble.tsx b/apps/rebreak-native/components/chat/ChatBubble.tsx
index 07e73c5..de0923e 100644
--- a/apps/rebreak-native/components/chat/ChatBubble.tsx
+++ b/apps/rebreak-native/components/chat/ChatBubble.tsx
@@ -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 && (
{isLastInGroup ? (
-
+
) : null}
)}
@@ -347,12 +351,6 @@ function makeStyles(colors: ReturnType) {
marginRight: 6,
justifyContent: 'flex-end',
},
- avatar: {
- width: 28,
- height: 28,
- borderRadius: 14,
- backgroundColor: colors.surfaceElevated,
- },
bubbleCol: {
maxWidth: '76%',
},
diff --git a/apps/rebreak-native/components/chat/ChatHeaderStatus.tsx b/apps/rebreak-native/components/chat/ChatHeaderStatus.tsx
new file mode 100644
index 0000000..7dccc62
--- /dev/null
+++ b/apps/rebreak-native/components/chat/ChatHeaderStatus.tsx
@@ -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): 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 (
+
+ {t('presence.online')}
+
+ );
+ }
+
+ const lastSeen = lastSeenMap[userId];
+ if (!lastSeen) return null;
+
+ return (
+
+ {formatLastSeen(lastSeen, t)}
+
+ );
+}
diff --git a/apps/rebreak-native/components/chat/ChatInput.tsx b/apps/rebreak-native/components/chat/ChatInput.tsx
index ae354d6..0abba2c 100644
--- a/apps/rebreak-native/components/chat/ChatInput.tsx
+++ b/apps/rebreak-native/components/chat/ChatInput.tsx
@@ -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 && (
{attachment.isImage ? (
-
+
) : (
@@ -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 ? (
) : (
-
+
)}
@@ -240,7 +240,7 @@ function decodeBase64(base64: string): Uint8Array {
function makeStyles(colors: ReturnType) {
return StyleSheet.create({
container: {
- backgroundColor: colors.surface,
+ backgroundColor: colors.bg,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.border,
},
@@ -301,35 +301,33 @@ function makeStyles(colors: ReturnType) {
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) {
borderRadius: 19,
alignItems: 'center',
justifyContent: 'center',
- marginLeft: 6,
},
});
}
diff --git a/apps/rebreak-native/components/chat/DmChatBackground.tsx b/apps/rebreak-native/components/chat/DmChatBackground.tsx
index 17368a4..4bad692 100644
--- a/apps/rebreak-native/components/chat/DmChatBackground.tsx
+++ b/apps/rebreak-native/components/chat/DmChatBackground.tsx
@@ -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() {
diff --git a/apps/rebreak-native/components/chat/RoomCard.tsx b/apps/rebreak-native/components/chat/RoomCard.tsx
index 44e2e4a..a630229 100644
--- a/apps/rebreak-native/components/chat/RoomCard.tsx
+++ b/apps/rebreak-native/components/chat/RoomCard.tsx
@@ -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) {
{room.avatarUrl ? (
-
+
) : !room.isPublic ? (
{initials}
) : (
diff --git a/apps/rebreak-native/components/mail/ConnectMailSheet.tsx b/apps/rebreak-native/components/mail/ConnectMailSheet.tsx
index 53ea8ba..9aa6139 100644
--- a/apps/rebreak-native/components/mail/ConnectMailSheet.tsx
+++ b/apps/rebreak-native/components/mail/ConnectMailSheet.tsx
@@ -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(null);
- const [fieldsComplete, setFieldsComplete] = useState(false);
const [oauthRunning, setOauthRunning] = useState(false);
const [oauthError, setOauthError] = useState(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' ? (
) : (
- { 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: (
- setPasswordVisible((p) => !p)}
- hitSlop={8}
- >
-
-
- ),
- },
- ]}
- intro={
-
- {/* App-Password-Guide — provider-spezifisch, nicht für 'other' */}
- {selectedProvider && selectedProvider.id !== 'other' && (
-
-
-
-
- {t('mail.app_password_required_title')}
-
-
- {t(selectedProvider.guideKey)}
-
- {selectedProvider.guideUrl.length > 0 && (
- Linking.openURL(selectedProvider.guideUrl)}
- >
-
- {t('mail.app_password_open_link')} →
-
-
- )}
-
-
- )}
-
- {/* Datenschutz-Zusicherung — immer sichtbar */}
-
-
-
- {t('mail.form_privacy_note')}
-
-
-
- }
- onComplete={() => setFieldsComplete(true)}
- >
- {/* Fehler */}
- {(formError ?? (connectError ? t(humanizeMailError(connectError)) : null)) && (
-
- {formError ?? t(humanizeMailError(connectError))}
-
- )}
-
- {/* Connect-Button */}
-
-
- {connecting ? (
-
- ) : (
-
- {t('mail.form_connect_btn')}
-
- )}
-
-
-
+
)}
);
}
+// ---------------------------------------------------------------------------
+// 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;
+}) {
+ const errorText = formError ?? (connectError ? t(humanizeMailError(connectError)) : null);
+
+ return (
+
+
+ {/* App-Password-Banner — provider-spezifisch, nicht für 'other' */}
+ {selectedProvider && selectedProvider.id !== 'other' && (
+
+
+
+
+ {t('mail.app_password_required_title')}
+
+
+ {t(selectedProvider.guideKey)}
+
+ {selectedProvider.guideUrl.length > 0 && (
+ Linking.openURL(selectedProvider.guideUrl)}
+ >
+
+ {t('mail.app_password_open_link')} →
+
+
+ )}
+
+
+ )}
+
+ {/* E-Mail */}
+
+
+ {t('mail.form_email_label')}
+
+
+ { 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,
+ }}
+ />
+
+
+
+ {/* App-Passwort */}
+
+
+ {t('mail.form_password_label')}
+
+
+ { 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,
+ }}
+ />
+ setPasswordVisible(!passwordVisible)}
+ hitSlop={8}
+ >
+
+
+
+ {/* AES-Verschlüsselungs-Hinweis als Footnote */}
+
+
+
+ {t('mail.form_privacy_note')}
+
+
+
+
+ {/* Bezeichnung */}
+
+
+ {t('mail.title_label')}
+
+
+
+
+
+
+ {/* Fehler */}
+ {errorText && (
+
+ {errorText}
+
+ )}
+
+ {/* Connect-Button */}
+
+
+ {connecting ? (
+
+ ) : (
+
+ {t('mail.form_connect_btn')}
+
+ )}
+
+
+
+
+ );
+}
+
// ---------------------------------------------------------------------------
// Sub-View: Consent (Art. 9 DSGVO) — muss als erster Schritt bestätigt werden
// ---------------------------------------------------------------------------
@@ -519,6 +625,7 @@ function ConsentStep({
{
+ 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]);
+}
diff --git a/apps/rebreak-native/hooks/useLastSeenBatch.ts b/apps/rebreak-native/hooks/useLastSeenBatch.ts
new file mode 100644
index 0000000..968bca2
--- /dev/null
+++ b/apps/rebreak-native/hooks/useLastSeenBatch.ts
@@ -0,0 +1,19 @@
+import { useQuery } from '@tanstack/react-query';
+import { apiFetch } from '../lib/api';
+
+type LastSeenMap = Record;
+
+export function useLastSeenBatch(userIds: string[]): LastSeenMap {
+ const sorted = [...userIds].sort();
+ const joinedKey = sorted.join(',');
+
+ const { data } = useQuery({
+ queryKey: ['last-seen', joinedKey],
+ queryFn: () =>
+ apiFetch(`/api/presence/last-seen?userIds=${encodeURIComponent(joinedKey)}`),
+ enabled: sorted.length > 0,
+ staleTime: 30_000,
+ });
+
+ return data ?? {};
+}
diff --git a/apps/rebreak-native/hooks/useLastSeenHeartbeat.ts b/apps/rebreak-native/hooks/useLastSeenHeartbeat.ts
new file mode 100644
index 0000000..0c0c11e
--- /dev/null
+++ b/apps/rebreak-native/hooks/useLastSeenHeartbeat.ts
@@ -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]);
+}
diff --git a/apps/rebreak-native/hooks/useMe.ts b/apps/rebreak-native/hooks/useMe.ts
index 26efb6d..8f5f630 100644
--- a/apps/rebreak-native/hooks/useMe.ts
+++ b/apps/rebreak-native/hooks/useMe.ts
@@ -38,6 +38,7 @@ export type Me = {
lyraVoiceId: string | null;
onboardingStep: OnboardingStep;
created_at?: string;
+ presenceVisible?: boolean;
};
let cachedMe: Me | null = null;
diff --git a/apps/rebreak-native/hooks/useOnlineUsers.ts b/apps/rebreak-native/hooks/useOnlineUsers.ts
new file mode 100644
index 0000000..c6d8232
--- /dev/null
+++ b/apps/rebreak-native/hooks/useOnlineUsers.ts
@@ -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;
+ isOnline: (userId: string) => boolean;
+};
+
+export const OnlinePresenceContext = createContext({
+ onlineUserIds: new Set(),
+ isOnline: () => false,
+});
+
+export function useOnlineUsers(): OnlinePresenceContext {
+ return useContext(OnlinePresenceContext);
+}
+
+let sharedChannel: RealtimeChannel | null = null;
+let subscriberCount = 0;
+let onlineUserIds: Set = new Set();
+const listeners = new Set<(ids: Set) => 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>(new Set(onlineUserIds));
+
+ useEffect(() => {
+ if (!currentUserId) return;
+
+ subscriberCount++;
+ ensureChannel(currentUserId);
+
+ const listener = (next: Set) => setIds(next);
+ listeners.add(listener);
+
+ return () => {
+ listeners.delete(listener);
+ subscriberCount--;
+ if (subscriberCount <= 0) {
+ subscriberCount = 0;
+ teardownChannel();
+ }
+ };
+ }, [currentUserId]);
+
+ return ids;
+}
diff --git a/apps/rebreak-native/locales/ar.json b/apps/rebreak-native/locales/ar.json
index 0dd2e12..5edf519 100644
--- a/apps/rebreak-native/locales/ar.json
+++ b/apps/rebreak-native/locales/ar.json
@@ -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} يوم"
}
}
diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json
index e853b86..a209a86 100644
--- a/apps/rebreak-native/locales/de.json
+++ b/apps/rebreak-native/locales/de.json
@@ -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.",
diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json
index 3108b2c..aa278cc 100644
--- a/apps/rebreak-native/locales/en.json
+++ b/apps/rebreak-native/locales/en.json
@@ -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"
}
}
diff --git a/apps/rebreak-native/locales/fr.json b/apps/rebreak-native/locales/fr.json
index 01fa76a..833dd60 100644
--- a/apps/rebreak-native/locales/fr.json
+++ b/apps/rebreak-native/locales/fr.json
@@ -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"
}
}
diff --git a/apps/rebreak-native/package.json b/apps/rebreak-native/package.json
index 33737e9..7b1c5cd 100644
--- a/apps/rebreak-native/package.json
+++ b/apps/rebreak-native/package.json
@@ -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",