feat(presence,sheets,chat): tester-build polish bundle
Online-Status (Phase 1+):
- UserAvatar mit 4 Size-Variants (sm/md/lg/xl) + integrierter Online-Dot
- OnlinePresenceProvider: Supabase-Channel + Following-Filter
- ChatHeaderStatus: "Online" neutral / "vor X min" offline
- useLastSeen + Heartbeat (60s interval + AppState-background ping)
- Privatsphäre-Toggle in profile/index
Sheets:
- FormSheet Android-keyboard-fix (Dimensions.get('screen'), kein
useWindowDimensions-Kollaps), useKeyboardHandler statt manual
Keyboard.addListener, state-reset on re-open
- PostCommentsSheet same Pattern + close-after-submit + drag bis under
app-header
- ConnectMailSheet form-view refactor: scrollable, AES-Banner als
footnote, field-order email→pw→label, fixed 0.85 über alle Steps
Chat:
- DmChatBackground iOS klecks fix (G transform statt nested Svg)
- ChatInput Lyra-1:1 (keyboardWillShow, surfaceElevated bubble,
arrow-up send, attachment links)
- dm/room/chat headers + conversation-list nutzen UserAvatar
- Foreign-Profile "Nachricht"-Button öffnet richtige DM
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
19b569927a
commit
5c539f8937
@ -1,4 +1,4 @@
|
|||||||
import { useState, useCallback, useEffect } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@ -6,7 +6,6 @@ import {
|
|||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
TextInput,
|
TextInput,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Image,
|
|
||||||
RefreshControl,
|
RefreshControl,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
@ -15,8 +14,8 @@ import { Ionicons } from '@expo/vector-icons';
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { apiFetch } from '../../lib/api';
|
import { apiFetch } from '../../lib/api';
|
||||||
import { resolveAvatar } from '../../lib/resolveAvatar';
|
|
||||||
import { AppHeader } from '../../components/AppHeader';
|
import { AppHeader } from '../../components/AppHeader';
|
||||||
|
import { UserAvatar } from '../../components/UserAvatar';
|
||||||
import { useColors } from '../../lib/theme';
|
import { useColors } from '../../lib/theme';
|
||||||
|
|
||||||
type DmConversation = {
|
type DmConversation = {
|
||||||
@ -43,25 +42,15 @@ function DmItem({ conv, onPress }: { conv: DmConversation; onPress: () => void }
|
|||||||
const styles = makeStyles(colors);
|
const styles = makeStyles(colors);
|
||||||
const hasUnread = conv.unreadCount > 0;
|
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 (
|
return (
|
||||||
<TouchableOpacity onPress={onPress} activeOpacity={0.7}>
|
<TouchableOpacity onPress={onPress} activeOpacity={0.7}>
|
||||||
<View style={styles.dmRow}>
|
<View style={styles.dmRow}>
|
||||||
<View style={styles.dmAvatar}>
|
<UserAvatar
|
||||||
{!avatarLoadFailed ? (
|
userId={conv.partnerId}
|
||||||
<Image
|
avatar={conv.partnerAvatar}
|
||||||
source={{ uri: avatarUrl }}
|
nickname={conv.partnerName}
|
||||||
style={styles.dmAvatarImg}
|
size="md"
|
||||||
onError={() => setAvatarLoadFailed(true)}
|
/>
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Text style={styles.dmAvatarInitials}>{avatarInitials}</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
<View style={styles.dmInfo}>
|
<View style={styles.dmInfo}>
|
||||||
<View style={styles.dmHeaderRow}>
|
<View style={styles.dmHeaderRow}>
|
||||||
<Text style={styles.dmName} numberOfLines={1}>
|
<Text style={styles.dmName} numberOfLines={1}>
|
||||||
@ -229,6 +218,7 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
|
|||||||
dmRow: {
|
dmRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
paddingVertical: 12,
|
paddingVertical: 12,
|
||||||
backgroundColor: colors.bg,
|
backgroundColor: colors.bg,
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { AppState, I18nManager } from 'react-native';
|
|||||||
|
|
||||||
I18nManager.allowRTL(true);
|
I18nManager.allowRTL(true);
|
||||||
import { Stack } from 'expo-router';
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
import * as Notifications from 'expo-notifications';
|
import * as Notifications from 'expo-notifications';
|
||||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||||
@ -29,6 +30,7 @@ import { useLyraVoiceStore } from '../stores/lyraVoice';
|
|||||||
import { BrandSplash } from '../components/BrandSplash';
|
import { BrandSplash } from '../components/BrandSplash';
|
||||||
import { AppLockGate } from '../components/AppLockGate';
|
import { AppLockGate } from '../components/AppLockGate';
|
||||||
import { DeviceLimitReachedSheet } from '../components/DeviceLimitReachedSheet';
|
import { DeviceLimitReachedSheet } from '../components/DeviceLimitReachedSheet';
|
||||||
|
import { OnlinePresenceProvider } from '../components/OnlinePresenceProvider';
|
||||||
import '../lib/i18n'; // i18next-Init via Side-Effect
|
import '../lib/i18n'; // i18next-Init via Side-Effect
|
||||||
import '../global.css';
|
import '../global.css';
|
||||||
|
|
||||||
@ -104,6 +106,7 @@ function RootLayoutInner() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<OnlinePresenceProvider>
|
||||||
<AppLockGate>
|
<AppLockGate>
|
||||||
<StatusBar style={colorScheme === 'dark' ? 'light' : 'dark'} />
|
<StatusBar style={colorScheme === 'dark' ? 'light' : 'dark'} />
|
||||||
<DeviceLimitReachedSheet />
|
<DeviceLimitReachedSheet />
|
||||||
@ -200,6 +203,7 @@ function RootLayoutInner() {
|
|||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</AppLockGate>
|
</AppLockGate>
|
||||||
|
</OnlinePresenceProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,10 +6,10 @@ import {
|
|||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
Platform,
|
Platform,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Image,
|
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
|
Keyboard,
|
||||||
|
KeyboardAvoidingView,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { KeyboardAvoidingView } from 'react-native-keyboard-controller';
|
|
||||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { useRouter, useLocalSearchParams } from 'expo-router';
|
import { useRouter, useLocalSearchParams } from 'expo-router';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
@ -18,12 +18,13 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { apiFetch } from '../lib/api';
|
import { apiFetch } from '../lib/api';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import { ChatBubble, type ChatMsg } from '../components/chat/ChatBubble';
|
import { ChatBubble, type ChatMsg } from '../components/chat/ChatBubble';
|
||||||
import { resolveAvatar } from '../lib/resolveAvatar';
|
|
||||||
import { ChatInput, type SendPayload } from '../components/chat/ChatInput';
|
import { ChatInput, type SendPayload } from '../components/chat/ChatInput';
|
||||||
import { DmChatBackground } from '../components/chat/DmChatBackground';
|
import { DmChatBackground } from '../components/chat/DmChatBackground';
|
||||||
import { useDmRealtime } from '../hooks/useChatRealtime';
|
import { useDmRealtime } from '../hooks/useChatRealtime';
|
||||||
import { useColors } from '../lib/theme';
|
import { useColors } from '../lib/theme';
|
||||||
import { useThemeStore } from '../stores/theme';
|
import { useThemeStore } from '../stores/theme';
|
||||||
|
import { UserAvatar } from '../components/UserAvatar';
|
||||||
|
import { ChatHeaderStatus } from '../components/chat/ChatHeaderStatus';
|
||||||
|
|
||||||
type DmHistoryResponse = {
|
type DmHistoryResponse = {
|
||||||
partner: {
|
partner: {
|
||||||
@ -66,6 +67,7 @@ export default function DmScreen() {
|
|||||||
|
|
||||||
const { userId } = useLocalSearchParams<{ userId: string }>();
|
const { userId } = useLocalSearchParams<{ userId: string }>();
|
||||||
|
|
||||||
|
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||||||
const [messages, setMessages] = useState<ChatMsg[]>([]);
|
const [messages, setMessages] = useState<ChatMsg[]>([]);
|
||||||
const [partner, setPartner] = useState<DmHistoryResponse['partner'] | null>(null);
|
const [partner, setPartner] = useState<DmHistoryResponse['partner'] | null>(null);
|
||||||
const partnerRef = useRef<DmHistoryResponse['partner'] | null>(null);
|
const partnerRef = useRef<DmHistoryResponse['partner'] | null>(null);
|
||||||
@ -74,6 +76,17 @@ export default function DmScreen() {
|
|||||||
);
|
);
|
||||||
const [sending, setSending] = useState(false);
|
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)
|
// Reset aller conversation-spezifischen States wenn userId wechselt (Stack-Reuse)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
@ -258,24 +271,26 @@ export default function DmScreen() {
|
|||||||
<Ionicons name="chevron-back" size={22} color={colors.text} />
|
<Ionicons name="chevron-back" size={22} color={colors.text} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<View style={styles.headerCenter}>
|
<View style={styles.headerCenter}>
|
||||||
<View style={styles.headerAvatar}>
|
<View style={{ marginRight: 8 }}>
|
||||||
{partner?.avatar ? (
|
<UserAvatar
|
||||||
<Image source={{ uri: resolveAvatar(partner.avatar, partner.nickname ?? '') }} style={styles.headerAvatarImg} />
|
userId={userId ?? null}
|
||||||
) : (
|
avatar={partner?.avatar ?? null}
|
||||||
<Text style={styles.headerAvatarInitials}>
|
nickname={partner?.nickname ?? '?'}
|
||||||
{(partner?.nickname ?? '?').slice(0, 2).toUpperCase()}
|
size="md"
|
||||||
</Text>
|
/>
|
||||||
)}
|
</View>
|
||||||
|
<View style={{ flexShrink: 1 }}>
|
||||||
|
<Text style={styles.headerName} numberOfLines={1}>
|
||||||
|
{partner?.nickname ?? '…'}
|
||||||
|
</Text>
|
||||||
|
{userId && <ChatHeaderStatus userId={userId} />}
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.headerName} numberOfLines={1}>
|
|
||||||
{partner?.nickname ?? '…'}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
behavior="padding"
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
keyboardVerticalOffset={0}
|
keyboardVerticalOffset={0}
|
||||||
>
|
>
|
||||||
<View style={{ flex: 1, backgroundColor: chatBg }}>
|
<View style={{ flex: 1, backgroundColor: chatBg }}>
|
||||||
@ -312,7 +327,7 @@ export default function DmScreen() {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={{ paddingBottom: Math.max(insets.bottom - 8, 0) }}>
|
<View style={{ paddingBottom: keyboardHeight > 0 ? 8 : Math.max(12, insets.bottom), backgroundColor: colors.bg }}>
|
||||||
<ChatInput
|
<ChatInput
|
||||||
replyTo={replyTo}
|
replyTo={replyTo}
|
||||||
sending={sending}
|
sending={sending}
|
||||||
@ -338,10 +353,7 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
|
|||||||
borderBottomColor: colors.border,
|
borderBottomColor: colors.border,
|
||||||
},
|
},
|
||||||
backBtn: {
|
backBtn: {
|
||||||
width: 36,
|
padding: 8,
|
||||||
height: 36,
|
|
||||||
borderRadius: 12,
|
|
||||||
backgroundColor: colors.surfaceElevated,
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
@ -351,22 +363,6 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginLeft: 8,
|
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: {
|
headerName: {
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontFamily: 'Nunito_700Bold',
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
|||||||
@ -1,55 +1,44 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { View, Text, ScrollView, TouchableOpacity, Image } from 'react-native';
|
import { View, Text, ScrollView, TouchableOpacity, Alert, ActivityIndicator } from 'react-native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useColors } from '../../lib/theme';
|
import { useColors } from '../../lib/theme';
|
||||||
import { resolveAvatar } from '../../lib/resolveAvatar';
|
import { apiFetch } from '../../lib/api';
|
||||||
import type { Plan } from '../../hooks/useUserPlan';
|
import { UserAvatar } from '../../components/UserAvatar';
|
||||||
|
import { PostCard } from '../../components/PostCard';
|
||||||
|
import { PostCardSkeleton } from '../../components/PostCardSkeleton';
|
||||||
|
import { PostCommentsSheet } from '../../components/PostCommentsSheet';
|
||||||
|
import { type CommunityPost } from '../../stores/community';
|
||||||
|
|
||||||
const planLabel: Record<Plan, string> = {
|
|
||||||
free: 'Free',
|
|
||||||
pro: 'Pro',
|
|
||||||
legend: 'Legend',
|
|
||||||
};
|
|
||||||
|
|
||||||
const planColors: Record<Plan, { bg: string; text: string; border: string }> = {
|
|
||||||
free: { bg: '#f5f5f5', text: '#525252', border: '#e5e5e5' },
|
|
||||||
pro: { bg: '#fff7ed', text: '#c2410c', border: '#fed7aa' },
|
|
||||||
legend: { bg: '#fef9c3', text: '#854d0e', border: '#fde68a' },
|
|
||||||
};
|
|
||||||
|
|
||||||
// TODO: GET /api/social/profile/[userId] — extend response um approvedDomainsCount.
|
|
||||||
// Strikt anonym: nur nickname, avatar, plan, memberSince, postsCount, followersCount,
|
|
||||||
// approvedDomainsCount, isFollowing. NIEMALS email, demographics, cooldowns, sos-insights.
|
|
||||||
type ForeignProfile = {
|
type ForeignProfile = {
|
||||||
id: string;
|
id: string;
|
||||||
nickname: string;
|
nickname: string;
|
||||||
avatar: string | null;
|
avatar: string | null;
|
||||||
plan: Plan;
|
tier: string;
|
||||||
memberSince: string;
|
totalPoints: number;
|
||||||
postsCount: number;
|
postsCount: number;
|
||||||
followersCount: number;
|
followersCount: number;
|
||||||
|
followingCount: number;
|
||||||
approvedDomainsCount: number;
|
approvedDomainsCount: number;
|
||||||
isFollowing: boolean;
|
isFollowing: boolean;
|
||||||
|
isSelf: boolean;
|
||||||
|
joinedAt: string;
|
||||||
|
recentPosts: unknown[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const DUMMY_FOREIGN: ForeignProfile = {
|
function formatJoinedAt(iso: string): string {
|
||||||
id: 'foreign-user-id',
|
try {
|
||||||
nickname: 'Jonas_42',
|
const d = new Date(iso);
|
||||||
avatar: 'wolf',
|
return d.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
|
||||||
plan: 'pro',
|
} catch {
|
||||||
memberSince: 'April 2026',
|
return '';
|
||||||
postsCount: 12,
|
}
|
||||||
followersCount: 47,
|
}
|
||||||
approvedDomainsCount: 8,
|
|
||||||
isFollowing: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
type StatProps = {
|
type StatProps = { value: string; label: string };
|
||||||
value: string;
|
|
||||||
label: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function ForeignStat({ value, label }: StatProps) {
|
function ForeignStat({ value, label }: StatProps) {
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
@ -69,14 +58,7 @@ function ForeignStat({ value, label }: StatProps) {
|
|||||||
<Text style={{ fontSize: 22, color: colors.text, fontFamily: 'Nunito_700Bold' }}>
|
<Text style={{ fontSize: 22, color: colors.text, fontFamily: 'Nunito_700Bold' }}>
|
||||||
{value}
|
{value}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text style={{ marginTop: 2, fontSize: 11, color: colors.textMuted, fontFamily: 'Nunito_600SemiBold' }}>
|
||||||
style={{
|
|
||||||
marginTop: 2,
|
|
||||||
fontSize: 11,
|
|
||||||
color: colors.textMuted,
|
|
||||||
fontFamily: 'Nunito_600SemiBold',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@ -87,18 +69,118 @@ export default function ForeignProfileScreen() {
|
|||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
|
const { t } = useTranslation();
|
||||||
const { userId } = useLocalSearchParams<{ userId: string }>();
|
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 [isFollowing, setIsFollowing] = useState(false);
|
||||||
const profile = DUMMY_FOREIGN;
|
const [localFollowersCount, setLocalFollowersCount] = useState<number | null>(null);
|
||||||
void userId;
|
const [followPending, setFollowPending] = useState(false);
|
||||||
|
const [activeCommentsPostId, setActiveCommentsPostId] = useState<string | null>(null);
|
||||||
|
|
||||||
const avatarUrl = resolveAvatar(profile.avatar, profile.nickname);
|
const openComments = useCallback((postId: string) => setActiveCommentsPostId(postId), []);
|
||||||
const initials = profile.nickname.slice(0, 2).toUpperCase();
|
const closeComments = useCallback(() => setActiveCommentsPostId(null), []);
|
||||||
const showImage = !!profile.avatar && !imageFailed;
|
|
||||||
const planStyle = planColors[profile.plan];
|
const { data: profile, isLoading, isError } = useQuery<ForeignProfile>({
|
||||||
|
queryKey: ['foreign-profile', userId],
|
||||||
|
queryFn: () => apiFetch<ForeignProfile>(`/api/social/profile/${userId}`),
|
||||||
|
enabled: !!userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: userPosts = [], isLoading: postsLoading } = useQuery<CommunityPost[]>({
|
||||||
|
queryKey: ['community-posts', { userId }],
|
||||||
|
queryFn: () => apiFetch<CommunityPost[]>(`/api/community/posts?userId=${userId}&limit=20`),
|
||||||
|
enabled: !!userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!profile) return;
|
||||||
|
if (profile.isSelf) {
|
||||||
|
router.replace('/profile');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsFollowing(profile.isFollowing);
|
||||||
|
setLocalFollowersCount(profile.followersCount);
|
||||||
|
}, [profile]);
|
||||||
|
|
||||||
|
async function handleFollow() {
|
||||||
|
if (followPending || !profile) return;
|
||||||
|
const optimisticFollowing = !isFollowing;
|
||||||
|
const optimisticCount = (localFollowersCount ?? profile.followersCount) + (optimisticFollowing ? 1 : -1);
|
||||||
|
setIsFollowing(optimisticFollowing);
|
||||||
|
setLocalFollowersCount(optimisticCount);
|
||||||
|
setFollowPending(true);
|
||||||
|
try {
|
||||||
|
const res = await apiFetch<{ following: boolean; followersCount: number }>(
|
||||||
|
'/api/social/follow',
|
||||||
|
{ method: 'POST', body: { userId: profile.id } },
|
||||||
|
);
|
||||||
|
setIsFollowing(res.following);
|
||||||
|
setLocalFollowersCount(res.followersCount);
|
||||||
|
} catch {
|
||||||
|
setIsFollowing(!optimisticFollowing);
|
||||||
|
setLocalFollowersCount(localFollowersCount ?? profile.followersCount);
|
||||||
|
Alert.alert(t('common.error'), t('common.unknown_error'));
|
||||||
|
} finally {
|
||||||
|
setFollowPending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: colors.groupedBg }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingTop: insets.top,
|
||||||
|
backgroundColor: colors.groupedBg,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ height: 56, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12 }}>
|
||||||
|
<TouchableOpacity onPress={() => router.back()} hitSlop={8} activeOpacity={0.5} style={{ padding: 8 }}>
|
||||||
|
<Ionicons name="chevron-back" size={22} color={colors.text} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<ActivityIndicator color={colors.brandOrange} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !profile) {
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: colors.groupedBg }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingTop: insets.top,
|
||||||
|
backgroundColor: colors.groupedBg,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ height: 56, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12 }}>
|
||||||
|
<TouchableOpacity onPress={() => router.back()} hitSlop={8} activeOpacity={0.5} style={{ padding: 8 }}>
|
||||||
|
<Ionicons name="chevron-back" size={22} color={colors.text} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 24 }}>
|
||||||
|
<Text style={{ fontSize: 14, color: colors.textMuted, fontFamily: 'Nunito_400Regular', textAlign: 'center', marginBottom: 16 }}>
|
||||||
|
{t('common.unknown_error')}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity onPress={() => router.back()} activeOpacity={0.7}>
|
||||||
|
<Text style={{ fontSize: 14, color: colors.brandOrange, fontFamily: 'Nunito_600SemiBold' }}>
|
||||||
|
{t('common.back')}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayFollowers = localFollowersCount ?? profile.followersCount;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1, backgroundColor: colors.groupedBg }}>
|
<View style={{ flex: 1, backgroundColor: colors.groupedBg }}>
|
||||||
@ -119,12 +201,7 @@ export default function ForeignProfileScreen() {
|
|||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TouchableOpacity
|
<TouchableOpacity onPress={() => router.back()} hitSlop={8} activeOpacity={0.5} style={{ padding: 8 }}>
|
||||||
onPress={() => router.back()}
|
|
||||||
hitSlop={8}
|
|
||||||
activeOpacity={0.5}
|
|
||||||
style={{ padding: 8 }}
|
|
||||||
>
|
|
||||||
<Ionicons name="chevron-back" size={22} color={colors.text} />
|
<Ionicons name="chevron-back" size={22} color={colors.text} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<Text style={{ fontSize: 15, color: colors.text, fontFamily: 'Nunito_600SemiBold' }}>
|
<Text style={{ fontSize: 15, color: colors.text, fontFamily: 'Nunito_600SemiBold' }}>
|
||||||
@ -140,76 +217,27 @@ export default function ForeignProfileScreen() {
|
|||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
<View style={{ alignItems: 'center', paddingVertical: 24, paddingHorizontal: 20 }}>
|
<View style={{ alignItems: 'center', paddingVertical: 24, paddingHorizontal: 20 }}>
|
||||||
<View
|
<View>
|
||||||
style={{
|
<UserAvatar
|
||||||
width: 96,
|
userId={userId ?? null}
|
||||||
height: 96,
|
avatar={profile.avatar}
|
||||||
borderRadius: 48,
|
nickname={profile.nickname}
|
||||||
borderWidth: 2,
|
size="xl"
|
||||||
borderColor: planStyle.border,
|
/>
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
overflow: 'hidden',
|
|
||||||
backgroundColor: showImage ? colors.surface : colors.brandOrange,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{showImage ? (
|
|
||||||
<Image
|
|
||||||
source={{ uri: avatarUrl }}
|
|
||||||
onError={() => setImageFailed(true)}
|
|
||||||
style={{ width: 92, height: 92, borderRadius: 46 }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Text style={{ color: '#fff', fontSize: 32, fontFamily: 'Nunito_700Bold' }}>
|
|
||||||
{initials}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text
|
<Text style={{ marginTop: 16, fontSize: 22, color: colors.text, fontFamily: 'Nunito_700Bold' }}>
|
||||||
style={{
|
|
||||||
marginTop: 16,
|
|
||||||
fontSize: 22,
|
|
||||||
color: colors.text,
|
|
||||||
fontFamily: 'Nunito_700Bold',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{profile.nickname}
|
{profile.nickname}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginTop: 8 }}>
|
<Text style={{ marginTop: 6, fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>
|
||||||
<View
|
Mitglied seit {formatJoinedAt(profile.joinedAt)}
|
||||||
style={{
|
</Text>
|
||||||
paddingHorizontal: 10,
|
|
||||||
paddingVertical: 3,
|
|
||||||
borderRadius: 999,
|
|
||||||
backgroundColor: planStyle.bg,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: planStyle.border,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 11,
|
|
||||||
color: planStyle.text,
|
|
||||||
fontFamily: 'Nunito_700Bold',
|
|
||||||
letterSpacing: 0.4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{planLabel[profile.plan].toUpperCase()}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Text style={{ fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>
|
|
||||||
Mitglied seit {profile.memberSince}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={{ flexDirection: 'row', gap: 8, marginTop: 16, width: '100%' }}>
|
<View style={{ flexDirection: 'row', gap: 8, marginTop: 16, width: '100%' }}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={handleFollow}
|
||||||
// TODO: POST /api/social/follow/[userId] resp. DELETE bei unfollow
|
disabled={followPending}
|
||||||
setIsFollowing((v) => !v);
|
|
||||||
}}
|
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
>
|
>
|
||||||
@ -220,23 +248,15 @@ export default function ForeignProfileScreen() {
|
|||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: isFollowing ? colors.border : colors.brandOrange,
|
borderColor: isFollowing ? colors.border : colors.brandOrange,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
opacity: followPending ? 0.6 : 1,
|
||||||
}}>
|
}}>
|
||||||
<Text
|
<Text style={{ fontSize: 13, color: isFollowing ? colors.text : '#ffffff', fontFamily: 'Nunito_600SemiBold' }}>
|
||||||
style={{
|
|
||||||
fontSize: 13,
|
|
||||||
color: isFollowing ? colors.text : '#ffffff',
|
|
||||||
fontFamily: 'Nunito_600SemiBold',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isFollowing ? 'Folge ich' : 'Folgen'}
|
{isFollowing ? 'Folge ich' : 'Folgen'}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => router.push({ pathname: '/dm', params: { userId: profile.id } })}
|
||||||
// TODO: navigate to DM with this userId
|
|
||||||
router.push(`/dm`);
|
|
||||||
}}
|
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
>
|
>
|
||||||
@ -248,35 +268,22 @@ export default function ForeignProfileScreen() {
|
|||||||
borderColor: colors.border,
|
borderColor: colors.border,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
}}>
|
}}>
|
||||||
<Text
|
<Text style={{ fontSize: 13, color: colors.text, fontFamily: 'Nunito_600SemiBold' }}>
|
||||||
style={{
|
Nachricht
|
||||||
fontSize: 13,
|
</Text>
|
||||||
color: colors.text,
|
|
||||||
fontFamily: 'Nunito_600SemiBold',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Nachricht
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View
|
<View style={{ height: 1, backgroundColor: 'rgba(0,0,0,0.06)', marginHorizontal: 16 }} />
|
||||||
style={{
|
|
||||||
height: 1,
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.06)',
|
|
||||||
marginHorizontal: 16,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<View style={{ flexDirection: 'row', gap: 8, marginTop: 16, paddingHorizontal: 16 }}>
|
<View style={{ flexDirection: 'row', gap: 8, marginTop: 16, paddingHorizontal: 16 }}>
|
||||||
<ForeignStat value={String(profile.postsCount)} label="Posts" />
|
<ForeignStat value={String(profile.postsCount)} label="Posts" />
|
||||||
<ForeignStat value={String(profile.followersCount)} label="Follower" />
|
<ForeignStat value={String(displayFollowers)} label="Follower" />
|
||||||
<ForeignStat value={String(profile.approvedDomainsCount)} label="Approved" />
|
<ForeignStat value={String(profile.approvedDomainsCount)} label="Approved" />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* TODO: GET /api/community/posts?userId=... — letzte 5 Posts */}
|
|
||||||
<View style={{ marginTop: 24, paddingHorizontal: 16 }}>
|
<View style={{ marginTop: 24, paddingHorizontal: 16 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
@ -287,31 +294,43 @@ export default function ForeignProfileScreen() {
|
|||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
LETZTE POSTS
|
{t('community.recent_posts')}
|
||||||
</Text>
|
</Text>
|
||||||
<View
|
|
||||||
style={{
|
{postsLoading ? (
|
||||||
backgroundColor: colors.card,
|
<View>
|
||||||
borderWidth: 1,
|
<PostCardSkeleton />
|
||||||
borderColor: colors.border,
|
<PostCardSkeleton />
|
||||||
borderRadius: 14,
|
<PostCardSkeleton />
|
||||||
padding: 16,
|
</View>
|
||||||
alignItems: 'center',
|
) : userPosts.length === 0 ? (
|
||||||
}}
|
<View
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={{
|
style={{
|
||||||
fontSize: 12,
|
backgroundColor: colors.card,
|
||||||
color: colors.textMuted,
|
borderWidth: 1,
|
||||||
fontFamily: 'Nunito_400Regular',
|
borderColor: colors.border,
|
||||||
textAlign: 'center',
|
borderRadius: 14,
|
||||||
|
padding: 16,
|
||||||
|
alignItems: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Posts-Liste folgt in Phase C
|
<Text style={{ fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular', textAlign: 'center' }}>
|
||||||
</Text>
|
{t('community.no_posts')}
|
||||||
</View>
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
userPosts.map((post) => (
|
||||||
|
<PostCard key={post.id} post={post} onCommentPress={openComments} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
|
<PostCommentsSheet
|
||||||
|
postId={activeCommentsPostId}
|
||||||
|
visible={activeCommentsPostId !== null}
|
||||||
|
onClose={closeComments}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { useRef, useState } from 'react';
|
import { useRef, useState, useEffect } from 'react';
|
||||||
import { View, ScrollView, Text, Alert, findNodeHandle, UIManager } from 'react-native';
|
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 { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { AppHeader } from '../../components/AppHeader';
|
import { AppHeader } from '../../components/AppHeader';
|
||||||
@ -23,6 +24,7 @@ import {
|
|||||||
useDemographics,
|
useDemographics,
|
||||||
} from '../../hooks/useProfileData';
|
} from '../../hooks/useProfileData';
|
||||||
import { apiFetch } from '../../lib/api';
|
import { apiFetch } from '../../lib/api';
|
||||||
|
import { untrackSelf, retrackSelf } from '../../hooks/useOnlineUsers';
|
||||||
|
|
||||||
const EMPTY_COOLDOWNS: CooldownEntry[] = [];
|
const EMPTY_COOLDOWNS: CooldownEntry[] = [];
|
||||||
|
|
||||||
@ -88,11 +90,40 @@ export default function ProfileScreen() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
|
const { t } = useTranslation();
|
||||||
const [bannerDismissed, setBannerDismissed] = useState(false);
|
const [bannerDismissed, setBannerDismissed] = useState(false);
|
||||||
const [demographicsExpanded, setDemographicsExpanded] = useState(false);
|
const [demographicsExpanded, setDemographicsExpanded] = useState(false);
|
||||||
const { me } = useMe();
|
const { me } = useMe();
|
||||||
const { user } = useAuthStore();
|
const { user } = useAuthStore();
|
||||||
|
|
||||||
|
const [presenceVisible, setPresenceVisible] = useState<boolean>(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (me?.presenceVisible !== undefined) {
|
||||||
|
setPresenceVisible(me.presenceVisible);
|
||||||
|
}
|
||||||
|
}, [me?.presenceVisible]);
|
||||||
|
|
||||||
|
async function togglePresence() {
|
||||||
|
const next = !presenceVisible;
|
||||||
|
setPresenceVisible(next);
|
||||||
|
if (!next) {
|
||||||
|
untrackSelf();
|
||||||
|
} else if (user?.id) {
|
||||||
|
retrackSelf(user.id);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await apiFetch('/api/me/presence-visibility', { method: 'POST', body: { visible: next } });
|
||||||
|
} catch {
|
||||||
|
setPresenceVisible(!next);
|
||||||
|
if (next) {
|
||||||
|
untrackSelf();
|
||||||
|
} else if (user?.id) {
|
||||||
|
retrackSelf(user.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { stats: socialStats } = useSocialStats(me?.id);
|
const { stats: socialStats } = useSocialStats(me?.id);
|
||||||
const { domains: approvedDomainsData } = useApprovedDomains();
|
const { domains: approvedDomainsData } = useApprovedDomains();
|
||||||
const { cooldownHistory } = useCooldownHistory();
|
const { cooldownHistory } = useCooldownHistory();
|
||||||
@ -272,6 +303,41 @@ export default function ProfileScreen() {
|
|||||||
|
|
||||||
<ApprovedDomainsList domains={approvedDomainsData?.list ?? []} />
|
<ApprovedDomainsList domains={approvedDomainsData?.list ?? []} />
|
||||||
|
|
||||||
|
<View style={{ paddingHorizontal: 16, paddingTop: 24, gap: 8 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
letterSpacing: 0.8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('profile.privacy_section_title').toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
padding: 14,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ flex: 1, gap: 2 }}>
|
||||||
|
<Text style={{ fontSize: 14, color: colors.text, fontFamily: 'Nunito_600SemiBold' }}>
|
||||||
|
{t('profile.show_online_status')}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>
|
||||||
|
{t('profile.show_online_status_hint')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch value={presenceVisible} onValueChange={togglePresence} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View style={{ height: 24 }} />
|
<View style={{ height: 24 }} />
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import {
|
|||||||
FlatList,
|
FlatList,
|
||||||
Pressable,
|
Pressable,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
Image,
|
|
||||||
Modal,
|
Modal,
|
||||||
TextInput,
|
TextInput,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
@ -13,8 +12,10 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
|
Keyboard,
|
||||||
|
KeyboardAvoidingView,
|
||||||
} from 'react-native';
|
} 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 { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { useRouter, useLocalSearchParams } from 'expo-router';
|
import { useRouter, useLocalSearchParams } from 'expo-router';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
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 { ChatInput, type SendPayload } from '../components/chat/ChatInput';
|
||||||
import { useRoomRealtime } from '../hooks/useChatRealtime';
|
import { useRoomRealtime } from '../hooks/useChatRealtime';
|
||||||
import { useColors } from '../lib/theme';
|
import { useColors } from '../lib/theme';
|
||||||
|
import { useOnlineUsers } from '../hooks/useOnlineUsers';
|
||||||
|
import { UserAvatar } from '../components/UserAvatar';
|
||||||
|
|
||||||
const GROUP_GAP_MS = 5 * 60 * 1000;
|
const GROUP_GAP_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
@ -70,9 +73,11 @@ export default function RoomScreen() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const flatRef = useRef<FlatList>(null);
|
const flatRef = useRef<FlatList>(null);
|
||||||
const [myUserId, setMyUserId] = useState<string | undefined>();
|
const [myUserId, setMyUserId] = useState<string | undefined>();
|
||||||
|
const { isOnline } = useOnlineUsers();
|
||||||
|
|
||||||
const { roomId } = useLocalSearchParams<{ roomId: string }>();
|
const { roomId } = useLocalSearchParams<{ roomId: string }>();
|
||||||
|
|
||||||
|
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||||||
const [messages, setMessages] = useState<ChatMsg[]>([]);
|
const [messages, setMessages] = useState<ChatMsg[]>([]);
|
||||||
const [replyTo, setReplyTo] = useState<{ id: string; nickname: string; content: string } | null>(
|
const [replyTo, setReplyTo] = useState<{ id: string; nickname: string; content: string } | null>(
|
||||||
null,
|
null,
|
||||||
@ -86,6 +91,17 @@ export default function RoomScreen() {
|
|||||||
supabase.auth.getSession().then(({ data }) => setMyUserId(data.session?.user.id));
|
supabase.auth.getSession().then(({ data }) => setMyUserId(data.session?.user.id));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
||||||
|
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
|
||||||
|
const show = Keyboard.addListener(showEvent, (e) => setKeyboardHeight(e.endCoordinates.height));
|
||||||
|
const hide = Keyboard.addListener(hideEvent, () => setKeyboardHeight(0));
|
||||||
|
return () => {
|
||||||
|
show.remove();
|
||||||
|
hide.remove();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const { data, isLoading, refetch } = useQuery<RoomDetail>({
|
const { data, isLoading, refetch } = useQuery<RoomDetail>({
|
||||||
queryKey: ['chat-room', roomId],
|
queryKey: ['chat-room', roomId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@ -306,7 +322,7 @@ export default function RoomScreen() {
|
|||||||
<View style={styles.headerCenter}>
|
<View style={styles.headerCenter}>
|
||||||
<View style={styles.headerAvatar}>
|
<View style={styles.headerAvatar}>
|
||||||
{room?.avatarUrl ? (
|
{room?.avatarUrl ? (
|
||||||
<Image source={{ uri: room.avatarUrl }} style={styles.headerAvatarImg} />
|
<Image source={{ uri: room.avatarUrl }} style={styles.headerAvatarImg} resizeMode="cover" />
|
||||||
) : (
|
) : (
|
||||||
<Text style={styles.headerAvatarInitials}>{initials}</Text>
|
<Text style={styles.headerAvatarInitials}>{initials}</Text>
|
||||||
)}
|
)}
|
||||||
@ -315,11 +331,16 @@ export default function RoomScreen() {
|
|||||||
<Text style={styles.headerName} numberOfLines={1}>
|
<Text style={styles.headerName} numberOfLines={1}>
|
||||||
{room?.name ?? '…'}
|
{room?.name ?? '…'}
|
||||||
</Text>
|
</Text>
|
||||||
{room && (
|
{room && (() => {
|
||||||
<Text style={styles.headerSub} numberOfLines={1}>
|
const onlineCount = members.filter((m) => isOnline(m.userId)).length;
|
||||||
{t('chat.member_count', { n: room.memberCount })}
|
return (
|
||||||
</Text>
|
<Text style={styles.headerSub} numberOfLines={1}>
|
||||||
)}
|
{onlineCount > 0
|
||||||
|
? t('chat.member_count_online', { n: room.memberCount, online: onlineCount })
|
||||||
|
: t('chat.member_count', { n: room.memberCount })}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity style={styles.iconBtn} onPress={() => setSettingsOpen(true)} hitSlop={8} activeOpacity={0.7}>
|
<TouchableOpacity style={styles.iconBtn} onPress={() => setSettingsOpen(true)} hitSlop={8} activeOpacity={0.7}>
|
||||||
@ -360,7 +381,8 @@ export default function RoomScreen() {
|
|||||||
) : (
|
) : (
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
behavior="padding"
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
|
keyboardVerticalOffset={0}
|
||||||
>
|
>
|
||||||
<FlatList
|
<FlatList
|
||||||
ref={flatRef}
|
ref={flatRef}
|
||||||
@ -383,7 +405,7 @@ export default function RoomScreen() {
|
|||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
onContentSizeChange={() => flatRef.current?.scrollToEnd({ animated: false })}
|
onContentSizeChange={() => flatRef.current?.scrollToEnd({ animated: false })}
|
||||||
/>
|
/>
|
||||||
<View style={{ paddingBottom: Math.max(insets.bottom - 8, 0) }}>
|
<View style={{ paddingBottom: keyboardHeight > 0 ? 8 : Math.max(12, insets.bottom), backgroundColor: colors.bg }}>
|
||||||
<ChatInput
|
<ChatInput
|
||||||
replyTo={replyTo}
|
replyTo={replyTo}
|
||||||
sending={sending}
|
sending={sending}
|
||||||
@ -534,7 +556,7 @@ function RoomSettingsModal({
|
|||||||
style={modal.avatarWrap}
|
style={modal.avatarWrap}
|
||||||
>
|
>
|
||||||
{room.avatarUrl ? (
|
{room.avatarUrl ? (
|
||||||
<Image source={{ uri: room.avatarUrl }} style={modal.avatar} />
|
<Image source={{ uri: room.avatarUrl }} style={modal.avatar} resizeMode="cover" />
|
||||||
) : (
|
) : (
|
||||||
<View style={[modal.avatar, modal.avatarPlaceholder]}>
|
<View style={[modal.avatar, modal.avatarPlaceholder]}>
|
||||||
<Ionicons name="people" size={32} color="#737373" />
|
<Ionicons name="people" size={32} color="#737373" />
|
||||||
@ -595,14 +617,13 @@ function RoomSettingsModal({
|
|||||||
</Text>
|
</Text>
|
||||||
{members.map((m) => (
|
{members.map((m) => (
|
||||||
<View key={m.userId} style={modal.memberRow}>
|
<View key={m.userId} style={modal.memberRow}>
|
||||||
<View style={modal.memberAvatar}>
|
<View style={{ marginRight: 10 }}>
|
||||||
{m.avatar ? (
|
<UserAvatar
|
||||||
<Image source={{ uri: m.avatar }} style={modal.memberAvatarImg} />
|
userId={m.userId}
|
||||||
) : (
|
avatar={m.avatar}
|
||||||
<Text style={modal.memberInitials}>
|
nickname={m.nickname}
|
||||||
{m.nickname.slice(0, 2).toUpperCase()}
|
size="md"
|
||||||
</Text>
|
/>
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<Text style={modal.memberName}>{m.nickname}</Text>
|
<Text style={modal.memberName}>{m.nickname}</Text>
|
||||||
@ -658,10 +679,7 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
|
|||||||
borderBottomColor: colors.border,
|
borderBottomColor: colors.border,
|
||||||
},
|
},
|
||||||
iconBtn: {
|
iconBtn: {
|
||||||
width: 36,
|
padding: 8,
|
||||||
height: 36,
|
|
||||||
borderRadius: 12,
|
|
||||||
backgroundColor: colors.surfaceElevated,
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
@ -825,22 +843,6 @@ function makeModalStyles(colors: ReturnType<typeof useColors>) {
|
|||||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
borderBottomColor: colors.border,
|
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 },
|
memberName: { fontSize: 13, fontFamily: 'Nunito_600SemiBold', color: colors.text },
|
||||||
memberRole: { fontSize: 11, color: colors.textMuted, marginTop: 1, textTransform: 'capitalize' },
|
memberRole: { fontSize: 11, color: colors.textMuted, marginTop: 1, textTransform: 'capitalize' },
|
||||||
actionBtn: {
|
actionBtn: {
|
||||||
|
|||||||
@ -1,47 +1,48 @@
|
|||||||
import { ReactNode, useEffect, useRef, useState } from 'react';
|
import { ReactNode, useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Animated,
|
Animated,
|
||||||
|
Dimensions,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
Modal,
|
Modal,
|
||||||
PanResponder,
|
PanResponder,
|
||||||
Platform,
|
ScrollView,
|
||||||
Text,
|
Text,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
useWindowDimensions,
|
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
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';
|
import { useColors } from '../lib/theme';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App-weites Bottom-Sheet — DAS eine Pattern für alle Custom-Modals.
|
* App-weites Bottom-Sheet — DAS eine Pattern für alle Custom-Modals.
|
||||||
*
|
*
|
||||||
* Verallgemeinert das verifizierte `PostCommentsSheet`-Pattern:
|
* - **Default = Auto-Fit**: Sheet misst seinen Inhalt (interner ScrollView via
|
||||||
* - `<Modal transparent>` mit hellem (oder ganz ohne) Backdrop — verdunkelt den
|
* `onContentSizeChange`) und wird genau so hoch wie nötig. Reicht der Cap
|
||||||
* Main-Screen nie stark.
|
* nicht, scrollt der Inhalt intern.
|
||||||
* - **Standard-Header**: Grabber-Bar mittig + Titel **links**. KEINE
|
* - **Cap**: SCREEN_H − statusBar − `navHeaderOffset` (default 56dp). So
|
||||||
* „Fertig"/„Abbrechen"/„Zurück"-Buttons — Schließen = runterswipen / Backdrop-Tap.
|
* überschreitet das Sheet niemals den App-Nav-Header.
|
||||||
* - **Resizable**: Drag am Handle/Header zieht das Sheet größer/kleiner;
|
* - **Legacy-Mode**: Wer `initialHeightPct` setzt, bekommt das alte
|
||||||
* Drag nach unten unter `minHeightPct` (oder schneller Flick) → dismiss.
|
* Fixed-Pct-Layout mit `<View flex:1>` children-wrapper (backwards-compat).
|
||||||
* - **Höhe ≤ 75 % Screen**, IMMER (Drag + Keyboard-Expand sind hart gedeckelt).
|
* - **Keyboard (iOS + Android)**: `useKeyboardHandler` aus
|
||||||
* - **Keyboard-aware**: Tastatur auf → Sheet wächst um Tastatur-Höhe (gedeckelt),
|
* `react-native-keyboard-controller` liefert den Modal-aware nativen
|
||||||
* `paddingBottom: keyboardHeight` (iOS) schiebt den Inhalt exakt über die
|
* Keyboard-Frame. Sheet wächst um `keyboardHeight` (gedeckelt),
|
||||||
* Tastatur. Android: `windowSoftInputMode=adjustResize` im Manifest macht das.
|
* `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
|
* Driver-Trennung (sonst „Style property 'height' is not supported by native
|
||||||
* animated module"-Crash): äußere View animiert `height` im JS-Driver, innere
|
* animated module"-Crash): äußere View animiert `height` im JS-Driver, innere
|
||||||
* View animiert `transform: translateY` (Slide/Dismiss) im Native-Driver.
|
* View animiert `transform: translateY` (Slide/Dismiss) im Native-Driver.
|
||||||
*
|
|
||||||
* Der Inhalt (`children`) wird in einem `flex:1`-Wrapper unter dem Header
|
|
||||||
* gerendert — der Caller layoutet selbst (z.B. `flex:1`-ScrollView + Bottom-Bar
|
|
||||||
* für eine Input-Zeile, die dann automatisch über der Tastatur sitzt).
|
|
||||||
*
|
|
||||||
* Für progressive Mehr-Feld-Formulare (Mail-Account, Domain hinzufügen) kommt
|
|
||||||
* `<SheetFieldStack>` als Inhalt rein (Phase 2).
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const MAX_HEIGHT_PCT = 0.75; // harter Cap — nie höher
|
|
||||||
const DRAG_FLICK_VELOCITY = 1.5;
|
const 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 {
|
export interface FormSheetProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@ -49,18 +50,26 @@ export interface FormSheetProps {
|
|||||||
/** Titel links im Header. */
|
/** Titel links im Header. */
|
||||||
title: string;
|
title: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
/** Start-Höhe als Anteil der Screen-Höhe (0..0.75). Default 0.5. */
|
/**
|
||||||
|
* Wenn gesetzt → Legacy-Fixed-Pct-Mode (alter `<View flex:1>` children-wrap).
|
||||||
|
* Ohne diesen Prop läuft Auto-Fit: Sheet wächst genau auf Content-Höhe.
|
||||||
|
*/
|
||||||
initialHeightPct?: number;
|
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;
|
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;
|
backdropOpacity?: number;
|
||||||
/** Default true — Tap auf Backdrop schließt das Sheet. */
|
/** Default true — Tap auf Backdrop schließt das Sheet. */
|
||||||
dismissOnBackdrop?: boolean;
|
dismissOnBackdrop?: boolean;
|
||||||
/** Default true — fügt unten einen Safe-Area-Spacer ein wenn die Tastatur zu ist. */
|
/** Default true — fügt unten einen Safe-Area-Spacer ein wenn die Tastatur zu ist. */
|
||||||
safeAreaBottom?: boolean;
|
safeAreaBottom?: boolean;
|
||||||
/** Default true — Sheet wächst/expandiert wenn die Tastatur aufgeht. Für
|
/** Default true — Sheet wächst mit der Tastatur (Inputs bleiben sichtbar). */
|
||||||
* Sheets ohne Input egal; auf false setzen wenn man's bewusst nicht will. */
|
|
||||||
growWithKeyboard?: boolean;
|
growWithKeyboard?: boolean;
|
||||||
/** Border-Radius oben. Default 24. */
|
/** Border-Radius oben. Default 24. */
|
||||||
topRadius?: number;
|
topRadius?: number;
|
||||||
@ -71,8 +80,9 @@ export function FormSheet({
|
|||||||
onClose,
|
onClose,
|
||||||
title,
|
title,
|
||||||
children,
|
children,
|
||||||
initialHeightPct = 0.5,
|
initialHeightPct,
|
||||||
minHeightPct = 0.3,
|
minHeightPct = 0.25,
|
||||||
|
navHeaderOffset = DEFAULT_NAV_HEADER_OFFSET,
|
||||||
backdropOpacity = 0.12,
|
backdropOpacity = 0.12,
|
||||||
dismissOnBackdrop = true,
|
dismissOnBackdrop = true,
|
||||||
safeAreaBottom = true,
|
safeAreaBottom = true,
|
||||||
@ -81,68 +91,98 @@ export function FormSheet({
|
|||||||
}: FormSheetProps) {
|
}: FormSheetProps) {
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
// useWindowDimensions: live — auf Android schrumpft height bei offener Tastatur
|
// Dimensions.get('screen') = physische Screen-Höhe, statisch, ignoriert
|
||||||
// (adjustResize), daher dynamisch statt Dimensions.get (statisch beim Modul-Load).
|
// Keyboard-Resize auf Android. useWindowDimensions würde live schrumpfen
|
||||||
const { height: SCREEN_H } = useWindowDimensions();
|
// 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 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 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 keyboardHeightRef = useRef(0);
|
||||||
|
const userDraggedRef = useRef(false); // sobald user manuell zieht, kein Auto-Re-Fit mehr
|
||||||
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||||||
|
|
||||||
// Reset bei (Wieder-)Öffnen
|
// Reset bei (Wieder-)Öffnen
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) {
|
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);
|
dismissY.setValue(0);
|
||||||
currentHeight.current = initialHeight;
|
currentHeight.current = fallbackInitial;
|
||||||
|
userDraggedRef.current = false;
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [visible]);
|
}, [visible]);
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
Keyboard.dismiss();
|
Keyboard.dismiss();
|
||||||
sheetHeight.setValue(initialHeight);
|
sheetHeight.setValue(fallbackInitial);
|
||||||
dismissY.setValue(0);
|
dismissY.setValue(0);
|
||||||
currentHeight.current = initialHeight;
|
currentHeight.current = fallbackInitial;
|
||||||
|
userDraggedRef.current = false;
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Keyboard: Sheet wächst (gedeckelt) + paddingBottom schiebt Inhalt über die Tastatur
|
// Auto-Fit: ScrollView meldet seine natürliche Content-Höhe.
|
||||||
useEffect(() => {
|
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;
|
if (!growWithKeyboard) return;
|
||||||
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
Animated.timing(sheetHeight, {
|
||||||
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
|
toValue: Math.min(currentHeight.current + h, maxHeight),
|
||||||
const showSub = Keyboard.addListener(showEvent, (e) => {
|
duration: 220,
|
||||||
const h = e.endCoordinates.height;
|
useNativeDriver: false,
|
||||||
keyboardHeightRef.current = h;
|
}).start();
|
||||||
setKeyboardHeight(h);
|
};
|
||||||
Animated.timing(sheetHeight, {
|
|
||||||
toValue: Math.min(currentHeight.current + h, maxHeight),
|
useKeyboardHandler({
|
||||||
duration: Platform.OS === 'ios' ? e.duration ?? 250 : 200,
|
onStart: (e) => {
|
||||||
useNativeDriver: false,
|
'worklet';
|
||||||
}).start();
|
runOnJS(applyKeyboardHeight)(e.height);
|
||||||
});
|
},
|
||||||
const hideSub = Keyboard.addListener(hideEvent, (e) => {
|
onEnd: (e) => {
|
||||||
keyboardHeightRef.current = 0;
|
'worklet';
|
||||||
setKeyboardHeight(0);
|
runOnJS(applyKeyboardHeight)(e.height);
|
||||||
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]);
|
|
||||||
|
|
||||||
const panResponder = useRef(
|
const panResponder = useRef(
|
||||||
PanResponder.create({
|
PanResponder.create({
|
||||||
@ -150,8 +190,6 @@ export function FormSheet({
|
|||||||
onMoveShouldSetPanResponder: () => true,
|
onMoveShouldSetPanResponder: () => true,
|
||||||
onPanResponderTerminationRequest: () => false,
|
onPanResponderTerminationRequest: () => false,
|
||||||
onPanResponderMove: (_, g) => {
|
onPanResponderMove: (_, g) => {
|
||||||
// Drag rauf (dy<0) → höher. Mit offener Tastatur rechnen wir vom
|
|
||||||
// gewachsenen Stand aus.
|
|
||||||
const base = currentHeight.current + keyboardHeightRef.current;
|
const base = currentHeight.current + keyboardHeightRef.current;
|
||||||
const next = base - g.dy;
|
const next = base - g.dy;
|
||||||
sheetHeight.setValue(Math.max(dismissHeight - 60, Math.min(maxHeight + 16, next)));
|
sheetHeight.setValue(Math.max(dismissHeight - 60, Math.min(maxHeight + 16, next)));
|
||||||
@ -177,8 +215,8 @@ export function FormSheet({
|
|||||||
friction: 9,
|
friction: 9,
|
||||||
tension: 70,
|
tension: 70,
|
||||||
}).start();
|
}).start();
|
||||||
// „Ruhe"-Höhe = ohne Tastatur-Anteil merken
|
|
||||||
currentHeight.current = Math.max(0, clamped - keyboardHeightRef.current);
|
currentHeight.current = Math.max(0, clamped - keyboardHeightRef.current);
|
||||||
|
userDraggedRef.current = true; // ab jetzt Auto-Re-Fit ignorieren
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
).current;
|
).current;
|
||||||
@ -206,7 +244,9 @@ export function FormSheet({
|
|||||||
borderTopLeftRadius: topRadius,
|
borderTopLeftRadius: topRadius,
|
||||||
borderTopRightRadius: topRadius,
|
borderTopRightRadius: topRadius,
|
||||||
overflow: 'hidden',
|
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 }],
|
transform: [{ translateY: dismissY }],
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
shadowOffset: { width: 0, height: -2 },
|
shadowOffset: { width: 0, height: -2 },
|
||||||
@ -214,9 +254,12 @@ export function FormSheet({
|
|||||||
shadowRadius: 8,
|
shadowRadius: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Grabber-Bar (mittig, drag-area) */}
|
{/* Grabber-Bar (mittig, drag-area) — paddingY für 44pt-Hit-Area */}
|
||||||
<View {...dragHandlers} style={{ alignItems: 'center', paddingTop: 8, paddingBottom: 6 }}>
|
<View
|
||||||
<View style={{ width: 36, height: 5, borderRadius: 3, backgroundColor: colors.border }} />
|
{...dragHandlers}
|
||||||
|
style={{ alignItems: 'center', paddingTop: 14, paddingBottom: 12 }}
|
||||||
|
>
|
||||||
|
<View style={{ width: 42, height: 6, borderRadius: 3, backgroundColor: colors.border }} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Header: Titel links — keine Buttons. Auch drag-area. */}
|
{/* Header: Titel links — keine Buttons. Auch drag-area. */}
|
||||||
@ -236,7 +279,18 @@ export function FormSheet({
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Inhalt */}
|
{/* Inhalt */}
|
||||||
<View style={{ flex: 1 }}>{children}</View>
|
{autoMode ? (
|
||||||
|
<ScrollView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
onContentSizeChange={onContentSize}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollView>
|
||||||
|
) : (
|
||||||
|
<View style={{ flex: 1 }}>{children}</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Safe-Area-Spacer (nur wenn Tastatur zu) */}
|
{/* Safe-Area-Spacer (nur wenn Tastatur zu) */}
|
||||||
{safeAreaBottom && <View style={{ height: keyboardHeight > 0 ? 0 : insets.bottom }} />}
|
{safeAreaBottom && <View style={{ height: keyboardHeight > 0 ? 0 : insets.bottom }} />}
|
||||||
|
|||||||
62
apps/rebreak-native/components/OnlinePresenceProvider.tsx
Normal file
62
apps/rebreak-native/components/OnlinePresenceProvider.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { createContext, useContext, useMemo } from 'react';
|
||||||
|
import { OnlinePresenceContext, useOnlinePresenceNode } from '../hooks/useOnlineUsers';
|
||||||
|
import { useAuthStore } from '../stores/auth';
|
||||||
|
import { useLastSeenHeartbeat } from '../hooks/useLastSeenHeartbeat';
|
||||||
|
import { useFollowing } from '../hooks/useFollowing';
|
||||||
|
|
||||||
|
export type PresenceContextExtended = {
|
||||||
|
onlineUserIds: Set<string>;
|
||||||
|
isOnline: (userId: string) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PresenceVisibilityContext = createContext<{
|
||||||
|
presenceVisible: boolean;
|
||||||
|
setPresenceVisible: (v: boolean) => void;
|
||||||
|
}>({
|
||||||
|
presenceVisible: true,
|
||||||
|
setPresenceVisible: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function usePresenceVisibility() {
|
||||||
|
return useContext(PresenceVisibilityContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function OnlinePresenceProvider({ children }: Props) {
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const ids = useOnlinePresenceNode(user?.id ?? null);
|
||||||
|
const following = useFollowing();
|
||||||
|
|
||||||
|
useLastSeenHeartbeat(!!user);
|
||||||
|
|
||||||
|
// Debug-Log nur bei tatsächlichen state-changes (size geändert) — sonst
|
||||||
|
// hängt's an jedem Re-Render und spammed Metro.
|
||||||
|
useMemo(() => {
|
||||||
|
console.log(
|
||||||
|
'[presence] state — self=%s, onlineGlobal=%d, following=%d',
|
||||||
|
user?.id ?? 'none',
|
||||||
|
ids.size,
|
||||||
|
following.size,
|
||||||
|
);
|
||||||
|
}, [ids.size, following.size, user?.id]);
|
||||||
|
|
||||||
|
const ctx = useMemo(
|
||||||
|
() => ({
|
||||||
|
onlineUserIds: ids,
|
||||||
|
isOnline: (userId: string) => {
|
||||||
|
if (!user?.id || userId === user.id) return false;
|
||||||
|
return ids.has(userId) && following.has(userId);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[ids, following, user?.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OnlinePresenceContext.Provider value={ctx}>
|
||||||
|
{children}
|
||||||
|
</OnlinePresenceContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,16 +1,17 @@
|
|||||||
import { memo, useState, useCallback, useRef, useEffect } from 'react';
|
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 { Ionicons } from '@expo/vector-icons';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
import i18n from '../lib/i18n';
|
import i18n from '../lib/i18n';
|
||||||
import { apiFetch } from '../lib/api';
|
import { apiFetch } from '../lib/api';
|
||||||
import { resolveAvatar } from '../lib/resolveAvatar';
|
|
||||||
import { formatRelativeTime } from '../lib/formatTime';
|
import { formatRelativeTime } from '../lib/formatTime';
|
||||||
import { useCommunityStore, type CommunityPost } from '../stores/community';
|
import { useCommunityStore, type CommunityPost } from '../stores/community';
|
||||||
import { RiveAvatar } from './RiveAvatar';
|
import { RiveAvatar } from './RiveAvatar';
|
||||||
import { HeroShieldCheck } from './HeroShieldCheck';
|
import { HeroShieldCheck } from './HeroShieldCheck';
|
||||||
import { useColors } from '../lib/theme';
|
import { useColors } from '../lib/theme';
|
||||||
|
import { UserAvatar } from './UserAvatar';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Domain-Approval-Posts werden vom Backend in 4 Sprachen parallel via Groq
|
* 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 { t } = useTranslation();
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const router = useRouter();
|
||||||
// Granular selectors — subscribing to the whole store would re-render every
|
// Granular selectors — subscribing to the whole store would re-render every
|
||||||
// PostCard whenever any user likes any post (optimisticLikes mutates).
|
// PostCard whenever any user likes any post (optimisticLikes mutates).
|
||||||
const applyOptimisticLike = useCommunityStore((s) => s.applyOptimisticLike);
|
const applyOptimisticLike = useCommunityStore((s) => s.applyOptimisticLike);
|
||||||
@ -131,19 +133,8 @@ function PostCardImpl({ post, onCommentPress }: Props) {
|
|||||||
// regular users use the image/initials fallback path.
|
// regular users use the image/initials fallback path.
|
||||||
const isLyraPost = post.isBot && post.botType === 'lyra';
|
const isLyraPost = post.isBot && post.botType === 'lyra';
|
||||||
|
|
||||||
// Avatar: only render Image if author has avatar id; resolveAvatar returns the URL.
|
const avatarUserId = !post.isAnonymous && !isLyraPost ? displayAuthor.id ?? null : null;
|
||||||
// On image-load error or missing avatar id → initials fallback.
|
const avatarId = !post.isAnonymous && !isLyraPost ? displayAuthor.avatar ?? null : null;
|
||||||
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() || '?';
|
|
||||||
|
|
||||||
// domain_approved: extract domain name from Google favicon URL stored in imageUrl
|
// domain_approved: extract domain name from Google favicon URL stored in imageUrl
|
||||||
const approvedDomain = (() => {
|
const approvedDomain = (() => {
|
||||||
@ -240,24 +231,26 @@ function PostCardImpl({ post, onCommentPress }: Props) {
|
|||||||
|
|
||||||
{/* Author + Meta */}
|
{/* Author + Meta */}
|
||||||
<View className="flex-row items-start justify-between mb-2">
|
<View className="flex-row items-start justify-between mb-2">
|
||||||
<View className="flex-row items-center gap-2.5 flex-1">
|
<TouchableOpacity
|
||||||
{isLyraPost ? (
|
activeOpacity={!isLyraPost && !post.isAnonymous && !!displayAuthor.id ? 0.7 : 1}
|
||||||
// Lyra bot posts use the animated Rive avatar at sm (40px).
|
onPress={!isLyraPost && !post.isAnonymous && !!displayAuthor.id
|
||||||
// The RiveAvatar sm-variant has no border/shadow by design — fits tight in list.
|
? () => router.push(`/profile/${displayAuthor.id}`)
|
||||||
<RiveAvatar emotion="idle" size="sm" />
|
: undefined}
|
||||||
) : showAvatarImage ? (
|
style={{ flexDirection: 'row', alignItems: 'center', gap: 10, flex: 1 }}
|
||||||
<Image
|
>
|
||||||
source={{ uri: avatarUrl }}
|
<View style={{ position: 'relative' }}>
|
||||||
onError={() => setAvatarLoadFailed(true)}
|
{isLyraPost ? (
|
||||||
className="w-10 h-10 rounded-full bg-neutral-100"
|
<RiveAvatar emotion="idle" size="sm" />
|
||||||
/>
|
) : (
|
||||||
) : (
|
<UserAvatar
|
||||||
<View className="w-10 h-10 rounded-full bg-rebreak-500 items-center justify-center">
|
userId={avatarUserId}
|
||||||
<Text className="text-white text-xs" style={{ fontFamily: 'Nunito_700Bold' }}>
|
avatar={avatarId}
|
||||||
{avatarInitials}
|
nickname={authorLabel}
|
||||||
</Text>
|
size="md"
|
||||||
</View>
|
isBot={post.isBot}
|
||||||
)}
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
<View className="flex-1 min-w-0">
|
<View className="flex-1 min-w-0">
|
||||||
<Text style={{ fontSize: 14, color: colors.text, fontFamily: 'Nunito_600SemiBold' }} numberOfLines={1}>
|
<Text style={{ fontSize: 14, color: colors.text, fontFamily: 'Nunito_600SemiBold' }} numberOfLines={1}>
|
||||||
{authorLabel}
|
{authorLabel}
|
||||||
@ -266,7 +259,7 @@ function PostCardImpl({ post, onCommentPress }: Props) {
|
|||||||
<Text style={{ fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>{authorDescription}</Text>
|
<Text style={{ fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>{authorDescription}</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
<Text style={{ fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular', flexShrink: 0, marginLeft: 8, marginTop: 2 }}>
|
<Text style={{ fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular', flexShrink: 0, marginLeft: 8, marginTop: 2 }}>
|
||||||
{formatRelativeTime(post.createdAt)}
|
{formatRelativeTime(post.createdAt)}
|
||||||
</Text>
|
</Text>
|
||||||
@ -446,6 +439,7 @@ function DomainFavicon({ domain, size }: DomainFaviconProps) {
|
|||||||
<Image
|
<Image
|
||||||
source={{ uri }}
|
source={{ uri }}
|
||||||
style={{ width: size, height: size, borderRadius: 6 }}
|
style={{ width: size, height: size, borderRadius: 6 }}
|
||||||
|
resizeMode="cover"
|
||||||
onError={() => setFailed(true)}
|
onError={() => setFailed(true)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -7,22 +7,22 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
Platform,
|
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Animated,
|
Animated,
|
||||||
Image,
|
Dimensions,
|
||||||
PanResponder,
|
PanResponder,
|
||||||
useWindowDimensions,
|
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
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 { Ionicons } from '@expo/vector-icons';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { apiFetch } from '../lib/api';
|
import { apiFetch } from '../lib/api';
|
||||||
import { formatRelativeTime } from '../lib/formatTime';
|
import { formatRelativeTime } from '../lib/formatTime';
|
||||||
import { resolveAvatar } from '../lib/resolveAvatar';
|
|
||||||
import { useColors } from '../lib/theme';
|
import { useColors } from '../lib/theme';
|
||||||
import type { CommunityComment } from '../stores/community';
|
import type { CommunityComment } from '../stores/community';
|
||||||
|
import { UserAvatar } from './UserAvatar';
|
||||||
|
|
||||||
const EMOJIS = ['❤️', '🙌', '🔥', '👏', '😢', '😍', '😮', '😂'];
|
const EMOJIS = ['❤️', '🙌', '🔥', '👏', '😢', '😍', '😮', '😂'];
|
||||||
|
|
||||||
@ -43,11 +43,11 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
|
|||||||
const [replyTarget, setReplyTarget] = useState<{ id: string; nickname: string } | null>(null);
|
const [replyTarget, setReplyTarget] = useState<{ id: string; nickname: string } | null>(null);
|
||||||
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||||||
|
|
||||||
// useWindowDimensions: live-tracking. Auf Android schrumpft `height` wenn die
|
// Dimensions.get('screen') = physische Screen-Höhe, statisch, ignoriert
|
||||||
// Tastatur aufgeht (windowSoftInputMode=adjustResize) — daher dynamisch statt
|
// Keyboard-Resize. MAX bis unter App-Nav-Header (~56dp) damit User per Drag
|
||||||
// `Dimensions.get` (statisch beim Modul-Load).
|
// bis ganz oben ziehen kann (User-Feedback: "wie alle andere sheets").
|
||||||
const { height: SCREEN_HEIGHT } = useWindowDimensions();
|
const SCREEN_HEIGHT = Dimensions.get('screen').height;
|
||||||
const MAX_HEIGHT = SCREEN_HEIGHT * 0.75;
|
const MAX_HEIGHT = Math.max(300, SCREEN_HEIGHT - insets.top - 56);
|
||||||
const MIN_HEIGHT = SCREEN_HEIGHT * 0.35;
|
const MIN_HEIGHT = SCREEN_HEIGHT * 0.35;
|
||||||
const INITIAL_HEIGHT = SCREEN_HEIGHT * 0.65;
|
const INITIAL_HEIGHT = SCREEN_HEIGHT * 0.65;
|
||||||
|
|
||||||
@ -80,6 +80,10 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) {
|
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);
|
sheetHeight.setValue(INITIAL_HEIGHT);
|
||||||
dismissY.setValue(0);
|
dismissY.setValue(0);
|
||||||
currentHeight.current = INITIAL_HEIGHT;
|
currentHeight.current = INITIAL_HEIGHT;
|
||||||
@ -137,34 +141,32 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
|
|||||||
}),
|
}),
|
||||||
).current;
|
).current;
|
||||||
|
|
||||||
useEffect(() => {
|
// keyboard-controller: Modal-aware Frame-Werte für iOS+Android (siehe Memory
|
||||||
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
// feedback_use_keyboard_controller). Manuelles Keyboard.addListener war auf
|
||||||
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
|
// Android im Modal unzuverlässig (paddingBottom=0 → Input hinter Tastatur).
|
||||||
const showSub = Keyboard.addListener(showEvent, (e) => {
|
const applyKbdHeight = (h: number) => {
|
||||||
const h = e.endCoordinates.height;
|
if (!visible) return;
|
||||||
setKeyboardHeight(h);
|
setKeyboardHeight(h);
|
||||||
const expanded = Math.min(currentHeight.current + h, maxHeightRef.current);
|
const target = h > 0
|
||||||
Animated.spring(sheetHeight, {
|
? Math.min(currentHeight.current + h, maxHeightRef.current)
|
||||||
toValue: expanded,
|
: currentHeight.current;
|
||||||
useNativeDriver: false,
|
Animated.spring(sheetHeight, {
|
||||||
friction: 9,
|
toValue: target,
|
||||||
tension: 70,
|
useNativeDriver: false,
|
||||||
}).start();
|
friction: 9,
|
||||||
});
|
tension: 70,
|
||||||
const hideSub = Keyboard.addListener(hideEvent, () => {
|
}).start();
|
||||||
setKeyboardHeight(0);
|
};
|
||||||
Animated.spring(sheetHeight, {
|
useKeyboardHandler({
|
||||||
toValue: currentHeight.current,
|
onStart: (e) => {
|
||||||
useNativeDriver: false,
|
'worklet';
|
||||||
friction: 9,
|
runOnJS(applyKbdHeight)(e.height);
|
||||||
tension: 70,
|
},
|
||||||
}).start();
|
onEnd: (e) => {
|
||||||
});
|
'worklet';
|
||||||
return () => {
|
runOnJS(applyKbdHeight)(e.height);
|
||||||
showSub.remove();
|
},
|
||||||
hideSub.remove();
|
});
|
||||||
};
|
|
||||||
}, [sheetHeight]);
|
|
||||||
|
|
||||||
const { data: comments = [], isLoading } = useQuery<CommunityComment[]>({
|
const { data: comments = [], isLoading } = useQuery<CommunityComment[]>({
|
||||||
queryKey: ['post-comments', postId],
|
queryKey: ['post-comments', postId],
|
||||||
@ -192,12 +194,14 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
|
|||||||
setReplyTarget(null);
|
setReplyTarget(null);
|
||||||
queryClient.invalidateQueries({ queryKey: ['post-comments', postId] });
|
queryClient.invalidateQueries({ queryKey: ['post-comments', postId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['community-posts'] });
|
queryClient.invalidateQueries({ queryKey: ['community-posts'] });
|
||||||
|
// Sheet schließen nach erfolgreicher Comment-Abgabe (User-Feedback).
|
||||||
|
handleClose();
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
}, [text, postId, replyTarget, queryClient]);
|
}, [text, postId, replyTarget, queryClient, handleClose]);
|
||||||
|
|
||||||
const likeComment = useCallback(
|
const likeComment = useCallback(
|
||||||
async (comment: CommunityComment) => {
|
async (comment: CommunityComment) => {
|
||||||
@ -249,7 +253,7 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
|
|||||||
borderTopLeftRadius: 24,
|
borderTopLeftRadius: 24,
|
||||||
borderTopRightRadius: 24,
|
borderTopRightRadius: 24,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
paddingBottom: Platform.OS === 'ios' ? keyboardHeight : 0,
|
paddingBottom: keyboardHeight,
|
||||||
transform: [{ translateY: dismissY }],
|
transform: [{ translateY: dismissY }],
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
shadowOffset: { width: 0, height: -2 },
|
shadowOffset: { width: 0, height: -2 },
|
||||||
@ -460,42 +464,16 @@ function CommentRow({ comment, isReply = false, onReply, onLike }: CommentRowPro
|
|||||||
onLike();
|
onLike();
|
||||||
}, [heartScale, onLike]);
|
}, [heartScale, onLike]);
|
||||||
|
|
||||||
const avatarSize = isReply ? 24 : 32;
|
|
||||||
const avatarRadius = avatarSize / 2;
|
|
||||||
const resolvedAvatar = comment.authorAvatar
|
|
||||||
? resolveAvatar(comment.authorAvatar, comment.authorNickname ?? 'anonym')
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flexDirection: 'row', gap: 12, paddingVertical: 8 }}>
|
<View style={{ flexDirection: 'row', gap: 12, paddingVertical: 8 }}>
|
||||||
<View
|
<View style={{ marginTop: 2 }}>
|
||||||
style={{
|
<UserAvatar
|
||||||
width: avatarSize,
|
userId={!isReply ? (comment.authorId ?? null) : null}
|
||||||
height: avatarSize,
|
avatar={comment.authorAvatar ?? null}
|
||||||
borderRadius: avatarRadius,
|
nickname={comment.authorNickname ?? 'AN'}
|
||||||
backgroundColor: colors.surfaceElevated,
|
size="sm"
|
||||||
alignItems: 'center',
|
showOnlineIndicator={!isReply}
|
||||||
justifyContent: 'center',
|
/>
|
||||||
marginTop: 2,
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{resolvedAvatar ? (
|
|
||||||
<Image
|
|
||||||
source={{ uri: resolvedAvatar }}
|
|
||||||
style={{ width: avatarSize, height: avatarSize, borderRadius: avatarRadius }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: isReply ? 9 : 11,
|
|
||||||
fontFamily: 'Nunito_700Bold',
|
|
||||||
color: colors.textMuted,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(comment.authorNickname ?? 'AN').slice(0, 2).toUpperCase()}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={{ flex: 1, minWidth: 0 }}>
|
<View style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
|||||||
120
apps/rebreak-native/components/UserAvatar.tsx
Normal file
120
apps/rebreak-native/components/UserAvatar.tsx
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { View, Text, Image } from 'react-native';
|
||||||
|
import { useOnlineUsers } from '../hooks/useOnlineUsers';
|
||||||
|
import { resolveAvatar } from '../lib/resolveAvatar';
|
||||||
|
import { useColors } from '../lib/theme';
|
||||||
|
|
||||||
|
type Size = 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
userId: string | null;
|
||||||
|
avatar: string | null;
|
||||||
|
nickname: string;
|
||||||
|
size?: Size;
|
||||||
|
showOnlineIndicator?: boolean;
|
||||||
|
isBot?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SIZE_MAP: Record<
|
||||||
|
Size,
|
||||||
|
{ avatar: number; dot: number; border: number; font: number; inset: number }
|
||||||
|
> = {
|
||||||
|
// inset = bottom/right Offset, berechnet via `avatarRadius*0.293 - dotRadius`
|
||||||
|
// damit der Dot-Center exakt auf der Avatar-Perimeter bei 45° sitzt (4:30
|
||||||
|
// clock position). Konsistente Insta-Optik unabhängig vom Avatar-Size.
|
||||||
|
sm: { avatar: 28, dot: 8, border: 2, font: 11, inset: 0 },
|
||||||
|
md: { avatar: 40, dot: 11, border: 2.5, font: 14, inset: 0 },
|
||||||
|
lg: { avatar: 56, dot: 14, border: 3, font: 18, inset: 1 },
|
||||||
|
xl: { avatar: 96, dot: 18, border: 3, font: 32, inset: 5 },
|
||||||
|
};
|
||||||
|
|
||||||
|
function OnlineDot({ size, bgColor }: { size: Size; bgColor: string }) {
|
||||||
|
const s = SIZE_MAP[size];
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: s.inset,
|
||||||
|
right: s.inset,
|
||||||
|
width: s.dot,
|
||||||
|
height: s.dot,
|
||||||
|
borderRadius: s.dot / 2,
|
||||||
|
backgroundColor: '#22c55e',
|
||||||
|
borderWidth: s.border,
|
||||||
|
borderColor: bgColor,
|
||||||
|
shadowColor: '#22c55e',
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 2,
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
elevation: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserAvatar({
|
||||||
|
userId,
|
||||||
|
avatar,
|
||||||
|
nickname,
|
||||||
|
size = 'md',
|
||||||
|
showOnlineIndicator = true,
|
||||||
|
isBot = false,
|
||||||
|
}: Props) {
|
||||||
|
const colors = useColors();
|
||||||
|
const { isOnline } = useOnlineUsers();
|
||||||
|
const [imageFailed, setImageFailed] = useState(false);
|
||||||
|
|
||||||
|
const s = SIZE_MAP[size];
|
||||||
|
const radius = s.avatar / 2;
|
||||||
|
|
||||||
|
const hasImage = !!avatar && !isBot && !imageFailed;
|
||||||
|
const avatarUrl = hasImage ? resolveAvatar(avatar, nickname) : '';
|
||||||
|
const initials = (nickname.charAt(0) + (nickname.charAt(1) ?? '')).toUpperCase() || '?';
|
||||||
|
|
||||||
|
const showDot =
|
||||||
|
showOnlineIndicator !== false &&
|
||||||
|
!!userId &&
|
||||||
|
!isBot &&
|
||||||
|
isOnline(userId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ position: 'relative', width: s.avatar, height: s.avatar }}>
|
||||||
|
{hasImage ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: avatarUrl }}
|
||||||
|
onError={() => setImageFailed(true)}
|
||||||
|
style={{
|
||||||
|
width: s.avatar,
|
||||||
|
height: s.avatar,
|
||||||
|
borderRadius: radius,
|
||||||
|
backgroundColor: colors.surfaceElevated,
|
||||||
|
}}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: s.avatar,
|
||||||
|
height: s.avatar,
|
||||||
|
borderRadius: radius,
|
||||||
|
backgroundColor: colors.brandOrange,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: s.font,
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showDot && <OnlineDot size={size} bgColor={colors.bg} />}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2,8 +2,8 @@ import { useState } from 'react';
|
|||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
TouchableOpacity,
|
|
||||||
Image,
|
Image,
|
||||||
|
TouchableOpacity,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Modal,
|
Modal,
|
||||||
Platform,
|
Platform,
|
||||||
@ -11,9 +11,9 @@ import {
|
|||||||
import * as Clipboard from 'expo-clipboard';
|
import * as Clipboard from 'expo-clipboard';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { resolveAvatar } from '../../lib/resolveAvatar';
|
|
||||||
import { useColors } from '../../lib/theme';
|
import { useColors } from '../../lib/theme';
|
||||||
import { useThemeStore } from '../../stores/theme';
|
import { useThemeStore } from '../../stores/theme';
|
||||||
|
import { UserAvatar } from '../UserAvatar';
|
||||||
|
|
||||||
export type ChatMsg = {
|
export type ChatMsg = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -85,8 +85,6 @@ export function ChatBubble({
|
|||||||
const isImageOnly =
|
const isImageOnly =
|
||||||
!!msg.attachmentUrl && msg.attachmentType === 'image' && !msg.content && !msg.replyTo;
|
!!msg.attachmentUrl && msg.attachmentType === 'image' && !msg.content && !msg.replyTo;
|
||||||
const replyHasAttachment = msg.replyTo?.attachmentType === 'image';
|
const replyHasAttachment = msg.replyTo?.attachmentType === 'image';
|
||||||
const avatarUrl = resolveAvatar(msg.avatar, msg.nickname ?? '?');
|
|
||||||
|
|
||||||
const ownBubbleRadius = {
|
const ownBubbleRadius = {
|
||||||
borderTopLeftRadius: 14,
|
borderTopLeftRadius: 14,
|
||||||
borderTopRightRadius: isFirstInGroup ? 14 : 4,
|
borderTopRightRadius: isFirstInGroup ? 14 : 4,
|
||||||
@ -121,7 +119,13 @@ export function ChatBubble({
|
|||||||
{!msg.isOwn && (
|
{!msg.isOwn && (
|
||||||
<View style={styles.avatarSlot}>
|
<View style={styles.avatarSlot}>
|
||||||
{isLastInGroup ? (
|
{isLastInGroup ? (
|
||||||
<Image source={{ uri: avatarUrl }} style={styles.avatar} />
|
<UserAvatar
|
||||||
|
userId={msg.userId}
|
||||||
|
avatar={msg.avatar ?? null}
|
||||||
|
nickname={msg.nickname ?? '?'}
|
||||||
|
size="sm"
|
||||||
|
showOnlineIndicator={false}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@ -347,12 +351,6 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
|
|||||||
marginRight: 6,
|
marginRight: 6,
|
||||||
justifyContent: 'flex-end',
|
justifyContent: 'flex-end',
|
||||||
},
|
},
|
||||||
avatar: {
|
|
||||||
width: 28,
|
|
||||||
height: 28,
|
|
||||||
borderRadius: 14,
|
|
||||||
backgroundColor: colors.surfaceElevated,
|
|
||||||
},
|
|
||||||
bubbleCol: {
|
bubbleCol: {
|
||||||
maxWidth: '76%',
|
maxWidth: '76%',
|
||||||
},
|
},
|
||||||
|
|||||||
42
apps/rebreak-native/components/chat/ChatHeaderStatus.tsx
Normal file
42
apps/rebreak-native/components/chat/ChatHeaderStatus.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { Text } from 'react-native';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useOnlineUsers } from '../../hooks/useOnlineUsers';
|
||||||
|
import { useLastSeenBatch } from '../../hooks/useLastSeenBatch';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatLastSeen(ts: string, t: (key: string, opts?: Record<string, unknown>) => string): string {
|
||||||
|
const diff = Date.now() - new Date(ts).getTime();
|
||||||
|
if (diff < 60_000) return t('presence.just_now');
|
||||||
|
if (diff < 3_600_000) return t('presence.minutes_ago', { minutes: Math.floor(diff / 60_000) });
|
||||||
|
if (diff < 86_400_000) return t('presence.hours_ago', { hours: Math.floor(diff / 3_600_000) });
|
||||||
|
return t('presence.days_ago', { days: Math.floor(diff / 86_400_000) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatHeaderStatus({ userId }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { isOnline } = useOnlineUsers();
|
||||||
|
const lastSeenMap = useLastSeenBatch(isOnline(userId) ? [] : [userId]);
|
||||||
|
const online = isOnline(userId);
|
||||||
|
|
||||||
|
if (online) {
|
||||||
|
// User-Wunsch: „Online"-Text zeigen, aber NICHT grün (Dot im Avatar reicht
|
||||||
|
// als Farb-Signal). Neutraler `textMuted`-Grau-Ton.
|
||||||
|
return (
|
||||||
|
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: '#a3a3a3' }}>
|
||||||
|
{t('presence.online')}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastSeen = lastSeenMap[userId];
|
||||||
|
if (!lastSeen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: '#a3a3a3' }}>
|
||||||
|
{formatLastSeen(lastSeen, t)}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2,9 +2,9 @@ import { useState, useRef } from 'react';
|
|||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
|
Image,
|
||||||
TextInput,
|
TextInput,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
Image,
|
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Platform,
|
Platform,
|
||||||
@ -165,7 +165,7 @@ export function ChatInput({
|
|||||||
{attachment && (
|
{attachment && (
|
||||||
<View style={styles.attachBar}>
|
<View style={styles.attachBar}>
|
||||||
{attachment.isImage ? (
|
{attachment.isImage ? (
|
||||||
<Image source={{ uri: attachment.uri }} style={styles.attachImg} />
|
<Image source={{ uri: attachment.uri }} style={styles.attachImg} resizeMode="cover" />
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.attachFileIcon}>
|
<View style={styles.attachFileIcon}>
|
||||||
<Ionicons name="document" size={18} color="#737373" />
|
<Ionicons name="document" size={18} color="#737373" />
|
||||||
@ -211,13 +211,13 @@ export function ChatInput({
|
|||||||
disabled={!hasContent || sending || uploading || disabled}
|
disabled={!hasContent || sending || uploading || disabled}
|
||||||
style={[
|
style={[
|
||||||
styles.sendBtn,
|
styles.sendBtn,
|
||||||
{ backgroundColor: hasContent ? '#007AFF' : '#e5e5e5' },
|
{ backgroundColor: '#007AFF', opacity: hasContent ? 1 : 0.4 },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{sending || uploading ? (
|
{sending || uploading ? (
|
||||||
<ActivityIndicator size="small" color="#fff" />
|
<ActivityIndicator size="small" color="#fff" />
|
||||||
) : (
|
) : (
|
||||||
<Ionicons name="send" size={16} color={hasContent ? '#fff' : '#a3a3a3'} />
|
<Ionicons name="arrow-up" size={18} color="#fff" />
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
@ -240,7 +240,7 @@ function decodeBase64(base64: string): Uint8Array {
|
|||||||
function makeStyles(colors: ReturnType<typeof useColors>) {
|
function makeStyles(colors: ReturnType<typeof useColors>) {
|
||||||
return StyleSheet.create({
|
return StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: colors.bg,
|
||||||
borderTopWidth: StyleSheet.hairlineWidth,
|
borderTopWidth: StyleSheet.hairlineWidth,
|
||||||
borderTopColor: colors.border,
|
borderTopColor: colors.border,
|
||||||
},
|
},
|
||||||
@ -301,35 +301,33 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
|
|||||||
row: {
|
row: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'flex-end',
|
alignItems: 'flex-end',
|
||||||
paddingHorizontal: 8,
|
gap: 8,
|
||||||
|
paddingHorizontal: 12,
|
||||||
paddingTop: 8,
|
paddingTop: 8,
|
||||||
paddingBottom: 8,
|
paddingBottom: 8,
|
||||||
},
|
},
|
||||||
iconBtn: {
|
iconBtn: {
|
||||||
width: 36,
|
width: 38,
|
||||||
height: 36,
|
height: 38,
|
||||||
borderRadius: 18,
|
borderRadius: 19,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
marginRight: 4,
|
|
||||||
},
|
},
|
||||||
inputWrap: {
|
inputWrap: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: colors.bg,
|
backgroundColor: colors.surfaceElevated,
|
||||||
borderRadius: 22,
|
borderRadius: 22,
|
||||||
borderWidth: StyleSheet.hairlineWidth,
|
paddingVertical: 9,
|
||||||
borderColor: colors.border,
|
paddingHorizontal: 16,
|
||||||
paddingHorizontal: 14,
|
|
||||||
minHeight: 38,
|
minHeight: 38,
|
||||||
maxHeight: 120,
|
maxHeight: 120,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
input: {
|
input: {
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
lineHeight: 20,
|
|
||||||
fontFamily: 'Nunito_400Regular',
|
fontFamily: 'Nunito_400Regular',
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
paddingVertical: Platform.OS === 'ios' ? 9 : 5,
|
padding: 0,
|
||||||
},
|
},
|
||||||
sendBtn: {
|
sendBtn: {
|
||||||
width: 38,
|
width: 38,
|
||||||
@ -337,7 +335,6 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
|
|||||||
borderRadius: 19,
|
borderRadius: 19,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
marginLeft: 6,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useWindowDimensions, View } from 'react-native';
|
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';
|
import { useColors } from '../../lib/theme';
|
||||||
|
|
||||||
const TILE = 80;
|
const TILE = 80;
|
||||||
@ -91,8 +91,8 @@ export function DmChatBackground() {
|
|||||||
for (let c = 0; c < cols; c++) {
|
for (let c = 0; c < cols; c++) {
|
||||||
const offsetX = r % 2 === 0 ? 0 : TILE / 2;
|
const offsetX = r % 2 === 0 ? 0 : TILE / 2;
|
||||||
items.push({
|
items.push({
|
||||||
x: c * TILE + offsetX,
|
x: c * TILE + offsetX + TILE / 2,
|
||||||
y: r * TILE,
|
y: r * TILE + TILE / 2,
|
||||||
type: SEQUENCE[seq % SEQUENCE.length],
|
type: SEQUENCE[seq % SEQUENCE.length],
|
||||||
rotate: [0, 15, -10, 30, -20, 5, -15][seq % 7],
|
rotate: [0, 15, -10, 30, -20, 5, -15][seq % 7],
|
||||||
});
|
});
|
||||||
@ -106,16 +106,12 @@ export function DmChatBackground() {
|
|||||||
<View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }} pointerEvents="none">
|
<View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }} pointerEvents="none">
|
||||||
<Svg width={width} height={height}>
|
<Svg width={width} height={height}>
|
||||||
{symbols.map((s, i) => (
|
{symbols.map((s, i) => (
|
||||||
<Svg
|
<G
|
||||||
key={i}
|
key={i}
|
||||||
x={s.x - 10}
|
transform={`translate(${s.x - 10}, ${s.y - 10}) rotate(${s.rotate}, 10, 10)`}
|
||||||
y={s.y - 10}
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
>
|
>
|
||||||
<SymbolShape type={s.type} color={patternColor} />
|
<SymbolShape type={s.type} color={patternColor} />
|
||||||
</Svg>
|
</G>
|
||||||
))}
|
))}
|
||||||
</Svg>
|
</Svg>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@ -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 { Ionicons } from '@expo/vector-icons';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useColors } from '../../lib/theme';
|
import { useColors } from '../../lib/theme';
|
||||||
@ -43,7 +43,7 @@ export function RoomCard({ room, onPress }: Props) {
|
|||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<View style={[styles.avatar, { backgroundColor: room.isPublic ? '#EFF6FF' : colors.surfaceElevated }]}>
|
<View style={[styles.avatar, { backgroundColor: room.isPublic ? '#EFF6FF' : colors.surfaceElevated }]}>
|
||||||
{room.avatarUrl ? (
|
{room.avatarUrl ? (
|
||||||
<Image source={{ uri: room.avatarUrl }} style={styles.avatarImg} />
|
<Image source={{ uri: room.avatarUrl }} style={styles.avatarImg} resizeMode="cover" />
|
||||||
) : !room.isPublic ? (
|
) : !room.isPublic ? (
|
||||||
<Text style={styles.avatarInitials}>{initials}</Text>
|
<Text style={styles.avatarInitials}>{initials}</Text>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -5,18 +5,18 @@ import {
|
|||||||
ScrollView,
|
ScrollView,
|
||||||
Switch,
|
Switch,
|
||||||
Text,
|
Text,
|
||||||
|
TextInput,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import * as WebBrowser from 'expo-web-browser';
|
import * as WebBrowser from 'expo-web-browser';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { humanizeMailError } from '../../lib/mailErrors';
|
||||||
import { apiFetch } from '../../lib/api';
|
import { apiFetch } from '../../lib/api';
|
||||||
import { useColors } from '../../lib/theme';
|
import { useColors } from '../../lib/theme';
|
||||||
import { FormSheet } from '../FormSheet';
|
import { FormSheet } from '../FormSheet';
|
||||||
import { SheetFieldStack } from '../SheetFieldStack';
|
|
||||||
import { useMailConnectDraft } from '../../stores/mailConnectDraft';
|
import { useMailConnectDraft } from '../../stores/mailConnectDraft';
|
||||||
|
|
||||||
const CONSENT_VERSION = 'art9-mail-v1-2026-05-13';
|
const CONSENT_VERSION = 'art9-mail-v1-2026-05-13';
|
||||||
@ -98,7 +98,7 @@ const PROVIDERS: ProviderConfig[] = [
|
|||||||
* Drei Ansichten im selben Sheet (kein Navigations-Header):
|
* Drei Ansichten im selben Sheet (kein Navigations-Header):
|
||||||
* 1. Consent-Screen (Art. 9 DSGVO) — MUSS zuerst bestätigt werden
|
* 1. Consent-Screen (Art. 9 DSGVO) — MUSS zuerst bestätigt werden
|
||||||
* 2. Provider-Grid (6 Tiles) — nach Consent-Bestätigung freigeschaltet
|
* 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) {
|
export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -124,7 +124,6 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) {
|
|||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [passwordVisible, setPasswordVisible] = useState(false);
|
const [passwordVisible, setPasswordVisible] = useState(false);
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
const [fieldsComplete, setFieldsComplete] = useState(false);
|
|
||||||
const [oauthRunning, setOauthRunning] = useState(false);
|
const [oauthRunning, setOauthRunning] = useState(false);
|
||||||
const [oauthError, setOauthError] = useState<string | null>(null);
|
const [oauthError, setOauthError] = useState<string | null>(null);
|
||||||
|
|
||||||
@ -133,7 +132,6 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) {
|
|||||||
setPassword('');
|
setPassword('');
|
||||||
setPasswordVisible(false);
|
setPasswordVisible(false);
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
setFieldsComplete(false);
|
|
||||||
setOauthRunning(false);
|
setOauthRunning(false);
|
||||||
setOauthError(null);
|
setOauthError(null);
|
||||||
onClose();
|
onClose();
|
||||||
@ -162,7 +160,6 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) {
|
|||||||
setPassword('');
|
setPassword('');
|
||||||
setTitle(defaultTitleForProvider(provider));
|
setTitle(defaultTitleForProvider(provider));
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
setFieldsComplete(false);
|
|
||||||
setOauthError(null);
|
setOauthError(null);
|
||||||
if (provider.authMethod === 'oauth_microsoft') {
|
if (provider.authMethod === 'oauth_microsoft') {
|
||||||
setView('oauth_warning');
|
setView('oauth_warning');
|
||||||
@ -294,7 +291,10 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) {
|
|||||||
visible={visible}
|
visible={visible}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
title={sheetTitle}
|
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
|
growWithKeyboard
|
||||||
>
|
>
|
||||||
{view === 'consent' ? (
|
{view === 'consent' ? (
|
||||||
@ -318,186 +318,292 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) {
|
|||||||
) : view === 'oauth_pending' ? (
|
) : view === 'oauth_pending' ? (
|
||||||
<OAuthPendingStep t={t} colors={colors} />
|
<OAuthPendingStep t={t} colors={colors} />
|
||||||
) : (
|
) : (
|
||||||
<SheetFieldStack
|
<FormView
|
||||||
fields={[
|
selectedProvider={selectedProvider}
|
||||||
{
|
email={email}
|
||||||
key: 'email',
|
setEmail={setEmail}
|
||||||
label: t('mail.form_email_label'),
|
password={password}
|
||||||
placeholder: t('mail.form_email_placeholder'),
|
setPassword={setPassword}
|
||||||
value: email,
|
passwordVisible={passwordVisible}
|
||||||
onChangeText: (v) => { setEmail(v); setFormError(null); },
|
setPasswordVisible={setPasswordVisible}
|
||||||
keyboardType: 'email-address',
|
title={title}
|
||||||
autoCapitalize: 'none',
|
setTitle={setTitle}
|
||||||
autoCorrect: false,
|
formError={formError}
|
||||||
validate: (v) =>
|
setFormError={setFormError}
|
||||||
v.trim().length === 0 ? t('mail.form_fields_required') : undefined,
|
connectError={connectError}
|
||||||
},
|
connecting={connecting}
|
||||||
{
|
onConnect={handleConnect}
|
||||||
key: 'title',
|
t={t}
|
||||||
label: t('mail.title_label'),
|
colors={colors}
|
||||||
placeholder: t('mail.title_placeholder'),
|
/>
|
||||||
value: title,
|
|
||||||
onChangeText: setTitle,
|
|
||||||
autoCapitalize: 'sentences',
|
|
||||||
autoCorrect: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'password',
|
|
||||||
label: t('mail.form_password_label'),
|
|
||||||
placeholder: t('mail.form_password_placeholder'),
|
|
||||||
value: password,
|
|
||||||
onChangeText: (v) => { setPassword(v); setFormError(null); },
|
|
||||||
secureTextEntry: !passwordVisible,
|
|
||||||
autoCapitalize: 'none',
|
|
||||||
autoCorrect: false,
|
|
||||||
validate: (v) =>
|
|
||||||
v.trim().length === 0 ? t('mail.form_fields_required') : undefined,
|
|
||||||
suffix: (
|
|
||||||
<TouchableOpacity
|
|
||||||
activeOpacity={0.7}
|
|
||||||
onPress={() => setPasswordVisible((p) => !p)}
|
|
||||||
hitSlop={8}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name={passwordVisible ? 'eye-off-outline' : 'eye-outline'}
|
|
||||||
size={20}
|
|
||||||
color="#a3a3a3"
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
intro={
|
|
||||||
<View style={{ gap: 10 }}>
|
|
||||||
{/* App-Password-Guide — provider-spezifisch, nicht für 'other' */}
|
|
||||||
{selectedProvider && selectedProvider.id !== 'other' && (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: 'row',
|
|
||||||
gap: 10,
|
|
||||||
padding: 12,
|
|
||||||
backgroundColor: '#f0f7ff',
|
|
||||||
borderRadius: 12,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#bfdbfe',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name="information-circle"
|
|
||||||
size={18}
|
|
||||||
color="#1d4ed8"
|
|
||||||
style={{ marginTop: 1 }}
|
|
||||||
/>
|
|
||||||
<View style={{ flex: 1, gap: 4 }}>
|
|
||||||
<Text
|
|
||||||
style={{ fontSize: 12, fontFamily: 'Nunito_600SemiBold', color: '#1e3a8a' }}
|
|
||||||
>
|
|
||||||
{t('mail.app_password_required_title')}
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 12,
|
|
||||||
fontFamily: 'Nunito_400Regular',
|
|
||||||
color: '#1d4ed8',
|
|
||||||
lineHeight: 17,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t(selectedProvider.guideKey)}
|
|
||||||
</Text>
|
|
||||||
{selectedProvider.guideUrl.length > 0 && (
|
|
||||||
<TouchableOpacity
|
|
||||||
activeOpacity={0.7}
|
|
||||||
onPress={() => Linking.openURL(selectedProvider.guideUrl)}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 12,
|
|
||||||
fontFamily: 'Nunito_600SemiBold',
|
|
||||||
color: '#007AFF',
|
|
||||||
marginTop: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('mail.app_password_open_link')} →
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Datenschutz-Zusicherung — immer sichtbar */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: 'row',
|
|
||||||
gap: 8,
|
|
||||||
padding: 12,
|
|
||||||
backgroundColor: '#f0fdf4',
|
|
||||||
borderRadius: 12,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#bbf7d0',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name="shield-checkmark" size={16} color="#16a34a" style={{ marginTop: 1 }} />
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
fontSize: 12,
|
|
||||||
fontFamily: 'Nunito_400Regular',
|
|
||||||
color: '#166534',
|
|
||||||
lineHeight: 17,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('mail.form_privacy_note')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
onComplete={() => setFieldsComplete(true)}
|
|
||||||
>
|
|
||||||
{/* Fehler */}
|
|
||||||
{(formError ?? (connectError ? t(humanizeMailError(connectError)) : null)) && (
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 13,
|
|
||||||
fontFamily: 'Nunito_400Regular',
|
|
||||||
color: '#dc2626',
|
|
||||||
marginBottom: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formError ?? t(humanizeMailError(connectError))}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Connect-Button */}
|
|
||||||
<TouchableOpacity
|
|
||||||
activeOpacity={0.85}
|
|
||||||
onPress={handleConnect}
|
|
||||||
disabled={connecting}
|
|
||||||
style={{ marginTop: 4, marginBottom: 12 }}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
backgroundColor: connecting ? '#d4d4d4' : '#007AFF',
|
|
||||||
borderRadius: 14,
|
|
||||||
paddingVertical: 14,
|
|
||||||
alignItems: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{connecting ? (
|
|
||||||
<ActivityIndicator color="#fff" />
|
|
||||||
) : (
|
|
||||||
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: '#fff' }}>
|
|
||||||
{t('mail.form_connect_btn')}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</SheetFieldStack>
|
|
||||||
)}
|
)}
|
||||||
</FormSheet>
|
</FormSheet>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sub-View: Form (email → password → title, eine ScrollView)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function FormView({
|
||||||
|
selectedProvider,
|
||||||
|
email,
|
||||||
|
setEmail,
|
||||||
|
password,
|
||||||
|
setPassword,
|
||||||
|
passwordVisible,
|
||||||
|
setPasswordVisible,
|
||||||
|
title,
|
||||||
|
setTitle,
|
||||||
|
formError,
|
||||||
|
setFormError,
|
||||||
|
connectError,
|
||||||
|
connecting,
|
||||||
|
onConnect,
|
||||||
|
t,
|
||||||
|
colors,
|
||||||
|
}: {
|
||||||
|
selectedProvider: { id: string; guideKey: string; guideUrl: string } | null;
|
||||||
|
email: string;
|
||||||
|
setEmail: (v: string) => void;
|
||||||
|
password: string;
|
||||||
|
setPassword: (v: string) => void;
|
||||||
|
passwordVisible: boolean;
|
||||||
|
setPasswordVisible: (v: boolean) => void;
|
||||||
|
title: string;
|
||||||
|
setTitle: (v: string) => void;
|
||||||
|
formError: string | null;
|
||||||
|
setFormError: (v: string | null) => void;
|
||||||
|
connectError: string | null;
|
||||||
|
connecting: boolean;
|
||||||
|
onConnect: () => void;
|
||||||
|
t: (key: string) => string;
|
||||||
|
colors: ReturnType<typeof useColors>;
|
||||||
|
}) {
|
||||||
|
const errorText = formError ?? (connectError ? t(humanizeMailError(connectError)) : null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<ScrollView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
contentContainerStyle={{ padding: 16, gap: 16, paddingBottom: 24 }}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{/* App-Password-Banner — provider-spezifisch, nicht für 'other' */}
|
||||||
|
{selectedProvider && selectedProvider.id !== 'other' && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 10,
|
||||||
|
padding: 12,
|
||||||
|
backgroundColor: '#f0f7ff',
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#bfdbfe',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="information-circle"
|
||||||
|
size={18}
|
||||||
|
color="#1d4ed8"
|
||||||
|
style={{ marginTop: 1 }}
|
||||||
|
/>
|
||||||
|
<View style={{ flex: 1, gap: 4 }}>
|
||||||
|
<Text style={{ fontSize: 12, fontFamily: 'Nunito_600SemiBold', color: '#1e3a8a' }}>
|
||||||
|
{t('mail.app_password_required_title')}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
color: '#1d4ed8',
|
||||||
|
lineHeight: 17,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t(selectedProvider.guideKey)}
|
||||||
|
</Text>
|
||||||
|
{selectedProvider.guideUrl.length > 0 && (
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={0.7}
|
||||||
|
onPress={() => Linking.openURL(selectedProvider.guideUrl)}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
color: '#007AFF',
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('mail.app_password_open_link')} →
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* E-Mail */}
|
||||||
|
<View style={{ gap: 6 }}>
|
||||||
|
<Text style={{ fontSize: 12, fontFamily: 'Nunito_600SemiBold', color: colors.textMuted }}>
|
||||||
|
{t('mail.form_email_label')}
|
||||||
|
</Text>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.surfaceElevated,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
value={email}
|
||||||
|
onChangeText={(v) => { setEmail(v); setFormError(null); }}
|
||||||
|
placeholder={t('mail.form_email_placeholder')}
|
||||||
|
placeholderTextColor={colors.textMuted}
|
||||||
|
keyboardType="email-address"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
style={{
|
||||||
|
paddingVertical: 12,
|
||||||
|
fontSize: 15,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
color: colors.text,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* App-Passwort */}
|
||||||
|
<View style={{ gap: 6 }}>
|
||||||
|
<Text style={{ fontSize: 12, fontFamily: 'Nunito_600SemiBold', color: colors.textMuted }}>
|
||||||
|
{t('mail.form_password_label')}
|
||||||
|
</Text>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: colors.surfaceElevated,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
value={password}
|
||||||
|
onChangeText={(v) => { setPassword(v); setFormError(null); }}
|
||||||
|
placeholder={t('mail.form_password_placeholder')}
|
||||||
|
placeholderTextColor={colors.textMuted}
|
||||||
|
secureTextEntry={!passwordVisible}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 12,
|
||||||
|
fontSize: 15,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
color: colors.text,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={0.7}
|
||||||
|
onPress={() => setPasswordVisible(!passwordVisible)}
|
||||||
|
hitSlop={8}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={passwordVisible ? 'eye-off-outline' : 'eye-outline'}
|
||||||
|
size={20}
|
||||||
|
color="#a3a3a3"
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
{/* AES-Verschlüsselungs-Hinweis als Footnote */}
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 2 }}>
|
||||||
|
<Ionicons name="shield-checkmark" size={11} color="#16a34a" />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
color: colors.textMuted,
|
||||||
|
flex: 1,
|
||||||
|
lineHeight: 15,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('mail.form_privacy_note')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Bezeichnung */}
|
||||||
|
<View style={{ gap: 6 }}>
|
||||||
|
<Text style={{ fontSize: 12, fontFamily: 'Nunito_600SemiBold', color: colors.textMuted }}>
|
||||||
|
{t('mail.title_label')}
|
||||||
|
</Text>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.surfaceElevated,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
value={title}
|
||||||
|
onChangeText={setTitle}
|
||||||
|
placeholder={t('mail.title_placeholder')}
|
||||||
|
placeholderTextColor={colors.textMuted}
|
||||||
|
autoCapitalize="sentences"
|
||||||
|
autoCorrect={false}
|
||||||
|
style={{
|
||||||
|
paddingVertical: 12,
|
||||||
|
fontSize: 15,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
color: colors.text,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Fehler */}
|
||||||
|
{errorText && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
color: '#dc2626',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{errorText}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Connect-Button */}
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={0.85}
|
||||||
|
onPress={onConnect}
|
||||||
|
disabled={connecting}
|
||||||
|
style={{ marginTop: 4, marginBottom: 12 }}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: connecting ? '#d4d4d4' : '#007AFF',
|
||||||
|
borderRadius: 14,
|
||||||
|
paddingVertical: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{connecting ? (
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
) : (
|
||||||
|
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: '#fff' }}>
|
||||||
|
{t('mail.form_connect_btn')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Sub-View: Consent (Art. 9 DSGVO) — muss als erster Schritt bestätigt werden
|
// Sub-View: Consent (Art. 9 DSGVO) — muss als erster Schritt bestätigt werden
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -519,6 +625,7 @@ function ConsentStep({
|
|||||||
<ScrollView
|
<ScrollView
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
contentContainerStyle={{ padding: 20, gap: 16 }}
|
contentContainerStyle={{ padding: 20, gap: 16 }}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
@ -657,6 +764,7 @@ function OAuthWarningStep({
|
|||||||
<ScrollView
|
<ScrollView
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
contentContainerStyle={{ padding: 20, gap: 16 }}
|
contentContainerStyle={{ padding: 20, gap: 16 }}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
@ -803,6 +911,7 @@ function ProviderGrid({
|
|||||||
<ScrollView
|
<ScrollView
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
contentContainerStyle={{ padding: 20, gap: 12 }}
|
contentContainerStyle={{ padding: 20, gap: 12 }}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
|
|||||||
17
apps/rebreak-native/hooks/useFollowing.ts
Normal file
17
apps/rebreak-native/hooks/useFollowing.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { apiFetch } from '../lib/api';
|
||||||
|
|
||||||
|
export function useFollowing(): Set<string> {
|
||||||
|
const { data, error } = useQuery({
|
||||||
|
queryKey: ['me-following'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const r = await apiFetch<{ userIds: string[] }>('/api/me/following');
|
||||||
|
console.log('[presence] /api/me/following →', r.userIds?.length ?? 0, 'IDs:', r.userIds);
|
||||||
|
return r;
|
||||||
|
},
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
});
|
||||||
|
if (error) console.warn('[presence] /api/me/following ERROR:', error);
|
||||||
|
return useMemo(() => new Set(data?.userIds ?? []), [data]);
|
||||||
|
}
|
||||||
19
apps/rebreak-native/hooks/useLastSeenBatch.ts
Normal file
19
apps/rebreak-native/hooks/useLastSeenBatch.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { apiFetch } from '../lib/api';
|
||||||
|
|
||||||
|
type LastSeenMap = Record<string, string | null>;
|
||||||
|
|
||||||
|
export function useLastSeenBatch(userIds: string[]): LastSeenMap {
|
||||||
|
const sorted = [...userIds].sort();
|
||||||
|
const joinedKey = sorted.join(',');
|
||||||
|
|
||||||
|
const { data } = useQuery<LastSeenMap>({
|
||||||
|
queryKey: ['last-seen', joinedKey],
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<LastSeenMap>(`/api/presence/last-seen?userIds=${encodeURIComponent(joinedKey)}`),
|
||||||
|
enabled: sorted.length > 0,
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return data ?? {};
|
||||||
|
}
|
||||||
36
apps/rebreak-native/hooks/useLastSeenHeartbeat.ts
Normal file
36
apps/rebreak-native/hooks/useLastSeenHeartbeat.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { AppState, type AppStateStatus } from 'react-native';
|
||||||
|
import { apiFetch } from '../lib/api';
|
||||||
|
|
||||||
|
const ping = (reason: string) => {
|
||||||
|
apiFetch('/api/me/last-seen', { method: 'POST' })
|
||||||
|
.then((r) => console.log('[presence] heartbeat OK (' + reason + ')', r))
|
||||||
|
.catch((e) => console.warn('[presence] heartbeat FAIL (' + reason + '):', e?.message ?? e));
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useLastSeenHeartbeat(enabled: boolean) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
// 60s-Interval während App im Foreground
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (AppState.currentState === 'active') {
|
||||||
|
ping('interval');
|
||||||
|
}
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
// Phase-2-Alternative zur Edge-Function: bei App-Background sofort einen
|
||||||
|
// finalen Ping → lastSeenAt ist exakt der Schließ-Zeitpunkt, kein 60s-Lag
|
||||||
|
// mehr für den letzten-aktiv-Zustand. 90% Edge-Function-Gewinn ohne Setup.
|
||||||
|
const sub = AppState.addEventListener('change', (next: AppStateStatus) => {
|
||||||
|
if (next === 'background' || next === 'inactive') {
|
||||||
|
ping('background');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
sub.remove();
|
||||||
|
};
|
||||||
|
}, [enabled]);
|
||||||
|
}
|
||||||
@ -38,6 +38,7 @@ export type Me = {
|
|||||||
lyraVoiceId: string | null;
|
lyraVoiceId: string | null;
|
||||||
onboardingStep: OnboardingStep;
|
onboardingStep: OnboardingStep;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
|
presenceVisible?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
let cachedMe: Me | null = null;
|
let cachedMe: Me | null = null;
|
||||||
|
|||||||
100
apps/rebreak-native/hooks/useOnlineUsers.ts
Normal file
100
apps/rebreak-native/hooks/useOnlineUsers.ts
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import { createContext, useContext, useEffect, useRef, useState } from 'react';
|
||||||
|
import type { RealtimeChannel } from '@supabase/supabase-js';
|
||||||
|
import { supabase } from '../lib/supabase';
|
||||||
|
|
||||||
|
type OnlinePresenceContext = {
|
||||||
|
onlineUserIds: Set<string>;
|
||||||
|
isOnline: (userId: string) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OnlinePresenceContext = createContext<OnlinePresenceContext>({
|
||||||
|
onlineUserIds: new Set(),
|
||||||
|
isOnline: () => false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useOnlineUsers(): OnlinePresenceContext {
|
||||||
|
return useContext(OnlinePresenceContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
let sharedChannel: RealtimeChannel | null = null;
|
||||||
|
let subscriberCount = 0;
|
||||||
|
let onlineUserIds: Set<string> = new Set();
|
||||||
|
const listeners = new Set<(ids: Set<string>) => void>();
|
||||||
|
|
||||||
|
function notify() {
|
||||||
|
const snapshot = new Set(onlineUserIds);
|
||||||
|
listeners.forEach((fn) => fn(snapshot));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureChannel(currentUserId: string) {
|
||||||
|
if (sharedChannel) {
|
||||||
|
console.log('[presence] channel already exists, skip ensure');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[presence] ensureChannel — opening for user', currentUserId);
|
||||||
|
const ch = supabase.channel('presence:online', {
|
||||||
|
config: { presence: { key: currentUserId } },
|
||||||
|
});
|
||||||
|
sharedChannel = ch;
|
||||||
|
|
||||||
|
ch
|
||||||
|
.on('presence', { event: 'sync' }, () => {
|
||||||
|
const state = ch.presenceState();
|
||||||
|
const keys = Object.keys(state);
|
||||||
|
onlineUserIds = new Set(keys);
|
||||||
|
console.log('[presence] sync — online users:', keys.length, keys);
|
||||||
|
notify();
|
||||||
|
})
|
||||||
|
.subscribe(async (status: string) => {
|
||||||
|
console.log('[presence] subscribe status:', status);
|
||||||
|
if (status === 'SUBSCRIBED') {
|
||||||
|
const result = await ch.track({ userId: currentUserId, online_at: new Date().toISOString() });
|
||||||
|
console.log('[presence] track result:', result);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function teardownChannel() {
|
||||||
|
if (!sharedChannel) return;
|
||||||
|
sharedChannel.untrack().catch(() => {});
|
||||||
|
supabase.removeChannel(sharedChannel);
|
||||||
|
sharedChannel = null;
|
||||||
|
onlineUserIds = new Set();
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function untrackSelf() {
|
||||||
|
sharedChannel?.untrack().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function retrackSelf(currentUserId: string) {
|
||||||
|
sharedChannel
|
||||||
|
?.track({ userId: currentUserId, online_at: new Date().toISOString() })
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOnlinePresenceNode(currentUserId: string | null | undefined) {
|
||||||
|
const [ids, setIds] = useState<Set<string>>(new Set(onlineUserIds));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentUserId) return;
|
||||||
|
|
||||||
|
subscriberCount++;
|
||||||
|
ensureChannel(currentUserId);
|
||||||
|
|
||||||
|
const listener = (next: Set<string>) => setIds(next);
|
||||||
|
listeners.add(listener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener);
|
||||||
|
subscriberCount--;
|
||||||
|
if (subscriberCount <= 0) {
|
||||||
|
subscriberCount = 0;
|
||||||
|
teardownChannel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [currentUserId]);
|
||||||
|
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
@ -931,6 +931,7 @@
|
|||||||
"file_attachment": "ملف",
|
"file_attachment": "ملف",
|
||||||
"upload_failed": "فشل الرفع",
|
"upload_failed": "فشل الرفع",
|
||||||
"member_count": "%{n} أعضاء",
|
"member_count": "%{n} أعضاء",
|
||||||
|
"member_count_online": "%{n} أعضاء · %{online} متصل",
|
||||||
"pending_request": "طلبات الانضمام",
|
"pending_request": "طلبات الانضمام",
|
||||||
"approve": "قبول",
|
"approve": "قبول",
|
||||||
"reject": "رفض",
|
"reject": "رفض",
|
||||||
@ -1030,7 +1031,10 @@
|
|||||||
"hour_evening": "مساءً",
|
"hour_evening": "مساءً",
|
||||||
"hour_night": "ليلاً"
|
"hour_night": "ليلاً"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"privacy_section_title": "الخصوصية",
|
||||||
|
"show_online_status": "إظهار حالة الاتصال",
|
||||||
|
"show_online_status_hint": "فقط الأشخاص الذين تتابعهم يرون متى تكون متصلاً"
|
||||||
},
|
},
|
||||||
"demographics": {
|
"demographics": {
|
||||||
"employment_status_employed": "موظف",
|
"employment_status_employed": "موظف",
|
||||||
@ -1263,5 +1267,12 @@
|
|||||||
"crisis_emergency_desc": "إذا كنت أنت أو شخص بالقرب منك في خطر فوري اتصل فوراً بالطوارئ.",
|
"crisis_emergency_desc": "إذا كنت أنت أو شخص بالقرب منك في خطر فوري اتصل فوراً بالطوارئ.",
|
||||||
"crisis_emergency_cta": "112 — الطوارئ",
|
"crisis_emergency_cta": "112 — الطوارئ",
|
||||||
"crisis_disclaimer": "هذه الجهات مستقلة عن rebreak. نحيلك إليها ولكننا لا نُقدّم الإرشاد بأنفسنا."
|
"crisis_disclaimer": "هذه الجهات مستقلة عن rebreak. نحيلك إليها ولكننا لا نُقدّم الإرشاد بأنفسنا."
|
||||||
|
},
|
||||||
|
"presence": {
|
||||||
|
"online": "متصل",
|
||||||
|
"just_now": "الآن",
|
||||||
|
"minutes_ago": "منذ %{minutes} دقيقة",
|
||||||
|
"hours_ago": "منذ %{hours} ساعة",
|
||||||
|
"days_ago": "منذ %{days} يوم"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -931,6 +931,7 @@
|
|||||||
"file_attachment": "Datei",
|
"file_attachment": "Datei",
|
||||||
"upload_failed": "Upload fehlgeschlagen",
|
"upload_failed": "Upload fehlgeschlagen",
|
||||||
"member_count": "%{n} Mitglieder",
|
"member_count": "%{n} Mitglieder",
|
||||||
|
"member_count_online": "%{n} Mitglieder · %{online} online",
|
||||||
"pending_request": "Beitrittsanfragen",
|
"pending_request": "Beitrittsanfragen",
|
||||||
"approve": "Annehmen",
|
"approve": "Annehmen",
|
||||||
"reject": "Ablehnen",
|
"reject": "Ablehnen",
|
||||||
@ -979,7 +980,8 @@
|
|||||||
"vote_no": "Nein",
|
"vote_no": "Nein",
|
||||||
"vote_rejected": "Abgelehnt",
|
"vote_rejected": "Abgelehnt",
|
||||||
"vote_in_review": "In Prüfung",
|
"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": {
|
"streak": {
|
||||||
"label_one": "Tag",
|
"label_one": "Tag",
|
||||||
@ -1030,7 +1032,10 @@
|
|||||||
"hour_evening": "Abend",
|
"hour_evening": "Abend",
|
||||||
"hour_night": "Nacht"
|
"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": {
|
"demographics": {
|
||||||
"employment_status_employed": "angestellt",
|
"employment_status_employed": "angestellt",
|
||||||
@ -1264,6 +1269,13 @@
|
|||||||
"crisis_emergency_cta": "112 — Notruf",
|
"crisis_emergency_cta": "112 — Notruf",
|
||||||
"crisis_disclaimer": "Diese Stellen sind unabhängig von Rebreak. Wir verweisen weiter, beraten aber nicht selbst."
|
"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": {
|
"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_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.",
|
"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.",
|
||||||
|
|||||||
@ -931,6 +931,7 @@
|
|||||||
"file_attachment": "File",
|
"file_attachment": "File",
|
||||||
"upload_failed": "Upload failed",
|
"upload_failed": "Upload failed",
|
||||||
"member_count": "%{n} members",
|
"member_count": "%{n} members",
|
||||||
|
"member_count_online": "%{n} members · %{online} online",
|
||||||
"pending_request": "Join requests",
|
"pending_request": "Join requests",
|
||||||
"approve": "Approve",
|
"approve": "Approve",
|
||||||
"reject": "Reject",
|
"reject": "Reject",
|
||||||
@ -979,7 +980,8 @@
|
|||||||
"vote_no": "No",
|
"vote_no": "No",
|
||||||
"vote_rejected": "Rejected",
|
"vote_rejected": "Rejected",
|
||||||
"vote_in_review": "Under review",
|
"vote_in_review": "Under review",
|
||||||
"voted_thanks": "Thanks for your vote!"
|
"voted_thanks": "Thanks for your vote!",
|
||||||
|
"recent_posts": "RECENT POSTS"
|
||||||
},
|
},
|
||||||
"streak": {
|
"streak": {
|
||||||
"label_one": "day",
|
"label_one": "day",
|
||||||
@ -1030,7 +1032,10 @@
|
|||||||
"hour_evening": "Evening",
|
"hour_evening": "Evening",
|
||||||
"hour_night": "Night"
|
"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": {
|
"demographics": {
|
||||||
"employment_status_employed": "employed",
|
"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_desc": "If you or someone nearby is in immediate danger, call emergency services immediately.",
|
||||||
"crisis_emergency_cta": "112 — Emergency",
|
"crisis_emergency_cta": "112 — Emergency",
|
||||||
"crisis_disclaimer": "These services are independent of Rebreak. We refer you onward but do not offer counselling ourselves."
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -918,6 +918,7 @@
|
|||||||
"file_attachment": "Fichier",
|
"file_attachment": "Fichier",
|
||||||
"upload_failed": "Échec du téléversement",
|
"upload_failed": "Échec du téléversement",
|
||||||
"member_count": "%{n} membres",
|
"member_count": "%{n} membres",
|
||||||
|
"member_count_online": "%{n} membres · %{online} en ligne",
|
||||||
"pending_request": "Demandes d'adhésion",
|
"pending_request": "Demandes d'adhésion",
|
||||||
"approve": "Accepter",
|
"approve": "Accepter",
|
||||||
"reject": "Refuser",
|
"reject": "Refuser",
|
||||||
@ -1017,7 +1018,10 @@
|
|||||||
"hour_evening": "Soir",
|
"hour_evening": "Soir",
|
||||||
"hour_night": "Nuit"
|
"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": {
|
"demographics": {
|
||||||
"employment_status_employed": "salarié",
|
"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_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_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."
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "@trucko/rebreak-native",
|
"name": "rebreak-native",
|
||||||
"version": "0.3.0",
|
"version": "0.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "expo-router/entry",
|
"main": "expo-router/entry",
|
||||||
@ -36,7 +36,7 @@
|
|||||||
"expo-file-system": "~19.0.22",
|
"expo-file-system": "~19.0.22",
|
||||||
"expo-font": "~14.0.11",
|
"expo-font": "~14.0.11",
|
||||||
"expo-haptics": "^15.0.8",
|
"expo-haptics": "^15.0.8",
|
||||||
"expo-image-manipulator": "~14.0.7",
|
"expo-image-manipulator": "~14.0.7",
|
||||||
"expo-image-picker": "~17.0.11",
|
"expo-image-picker": "~17.0.11",
|
||||||
"expo-linking": "~8.0.12",
|
"expo-linking": "~8.0.12",
|
||||||
"expo-local-authentication": "~17.0.8",
|
"expo-local-authentication": "~17.0.8",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user