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() { {symbols.map((s, i) => ( - - + ))} 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",