feat(voice+chat): voice notes DM, chat list attachment preview, DiGA milestone modal

Voice Notes (DM):
- WhatsApp-style voice recording bar (shared VoiceRecordingBar component)
- Audio bubbles: 80 fixed-2dp bars (Instagram-style thin), space-between layout,
  deterministic waveform, moving blue position dot, WA gray bar colors
- Cancel flash fix: setIsVoiceRecording delayed 350ms so trash flash is visible
- Mic button 44pt (Apple min), hitSlop on all recording controls
- startReply shows 🎤/📷 label for voice/image instead of empty

Chat list:
- lastAttachmentType from backend (getDmConversations now selects attachmentType)
- Shows '🎤 Sprachnachricht' / '📷 Foto' / '📎 Medien' as fallback per type
- User search second stage: GET /api/users/search?q= + debounced frontend section
- Push preview: audio → '🎤 Sprachnachricht', image → '📷 Foto' (was '📎 Anhang')

Blocker iOS Layer 3 (Screen Time):
- ScreentimePasscodeCard visible in locked-in state (was hidden once both layers active)
- Confirmed status loaded from backend on mount
- Numbered step instructions (iOS has no deep link to passcode dialog)
- Guard: only for unsupervised VPN+FC path (!mdmManaged && !nefilterActive)
- URL fallback: App-Prefs:SCREEN_TIME → App-Prefs:root=SCREEN_TIME → openSettings

DiGA Milestone Modal:
- Day 3/7/10 celebratory bottom sheet with soft demographic data ask
- Per-user/milestone AsyncStorage tracking, never shows if demographics filled
- Opens DemographicsAccordion in profile via ?openDemo=1 param

Lyra coach: contextual DiGA demographic nudge (optional, positive moments only)
i18n: DE/EN/FR/AR for voice_message, photo, media_sent, mic_access, diga_milestone,
  screentime steps, chat search strings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-06-02 01:59:26 +02:00
parent 80165c851c
commit 2e49aad386
17 changed files with 1002 additions and 271 deletions

View File

@ -18,6 +18,7 @@ import { preloadTabIcons, getTabIcon } from '../../lib/tabIcons';
import { apiFetch } from '../../lib/api';
import { useQuery } from '@tanstack/react-query';
import { useMe } from '../../hooks/useMe';
import { DiGaMilestoneModal } from '../../components/DiGaMilestoneModal';
const ONBOARDING_COMPLETED_KEY = '@rebreak/protection-onboarding-completed';
const ANDROID_RESTART_PROMPT_KEY = '@rebreak/protection-restart-prompt-shown';
@ -380,6 +381,7 @@ export default function AppLayout() {
/>
<NativeTabs.Screen name="notifications" options={{ href: null }} />
</NativeTabs>
<DiGaMilestoneModal />
</>
);
}

View File

@ -89,6 +89,13 @@ export default function BlockerScreen() {
const [screentimeCode, setScreentimeCode] = useState<string | null>(null);
const [screentimeConfirmed, setScreentimeConfirmed] = useState(false);
const [screentimeSaving, setScreentimeSaving] = useState(false);
// Load persisted screentime status on mount so the card stays hidden if already set
useEffect(() => {
if (Platform.OS !== 'ios') return;
protection.getScreenTimePasscode().then((p) => {
if (p) setScreentimeConfirmed(true);
}).catch(() => {});
}, []);
const urlFilterActive = state?.layers.urlFilter === true;
const familyControlsActive = state?.layers.familyControls === true;
@ -367,21 +374,28 @@ export default function BlockerScreen() {
/>
) : null}
{/* iOS Layer 3 — Screen Time Passcode */}
{Platform.OS === 'ios' && FAMILY_CONTROLS_AVAILABLE && !mdmManaged && appDeletionLockActive && (
</View>
)}
{/* iOS Layer 3 — Screen Time Passcode: nur für unsupervised (VPN+FC), NICHT für MDM/NEFilter-Pfad */}
{Platform.OS === 'ios' && FAMILY_CONTROLS_AVAILABLE && !mdmManaged && !nefilterActive && (lockedIn || appDeletionLockActive) && !screentimeConfirmed && (
<ScreentimePasscodeCard
code={screentimeCode}
confirmed={screentimeConfirmed}
saving={screentimeSaving}
onGenerate={handleGenerateScreentimeCode}
onOpenSettings={() => Linking.openURL('App-Prefs:SCREEN_TIME').catch(() => Linking.openSettings())}
onOpenSettings={() =>
// iOS hat keinen Deep-Link zum Passcode-Dialog — wir öffnen Screen-Time-Hauptseite.
// Beide URL-Formate probieren (iOS-Versionen variieren).
Linking.openURL('App-Prefs:SCREEN_TIME')
.catch(() => Linking.openURL('App-Prefs:root=SCREEN_TIME'))
.catch(() => Linking.openSettings())
}
onConfirm={handleScreentimeConfirm}
colors={colors}
t={t}
/>
)}
</View>
)}
{/* CooldownBanner */}
{state.cooldown.active && (
@ -570,6 +584,7 @@ function ScreentimePasscodeCard({
</TouchableOpacity>
) : (
<View style={{ gap: 10 }}>
{/* Code display */}
<View style={{ backgroundColor: colors.surfaceElevated, borderRadius: 12, padding: 16, alignItems: 'center', gap: 4 }}>
<Text style={{ fontSize: 11, fontFamily: 'Nunito_600SemiBold', color: colors.textMuted }}>
{t('blocker.screentime_code_label')}
@ -577,19 +592,36 @@ function ScreentimePasscodeCard({
<Text style={{ fontSize: 36, fontFamily: 'Nunito_700Bold', color: colors.brandOrange, letterSpacing: 12 }}>
{code}
</Text>
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: colors.textMuted, textAlign: 'center' }}>
{t('blocker.screentime_code_hint')}
</View>
{/* Step-by-step instructions */}
<View style={{ backgroundColor: colors.surfaceElevated, borderRadius: 12, padding: 12, gap: 6 }}>
{[
t('blocker.screentime_step1'),
t('blocker.screentime_step2'),
t('blocker.screentime_step3'),
].map((step, i) => (
<Text key={i} style={{ fontSize: 13, fontFamily: i === 0 ? 'Nunito_700Bold' : 'Nunito_600SemiBold', color: i === 0 ? colors.brandOrange : colors.text, lineHeight: 18 }}>
{step}
</Text>
))}
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: colors.textMuted, lineHeight: 16, marginTop: 2 }}>
{t('blocker.screentime_step_note')}
</Text>
</View>
{/* Open Settings */}
<TouchableOpacity
onPress={onOpenSettings}
activeOpacity={0.8}
style={{ backgroundColor: colors.surfaceElevated, borderRadius: 10, paddingVertical: 10, alignItems: 'center' }}
style={{ backgroundColor: '#007AFF', borderRadius: 10, paddingVertical: 10, alignItems: 'center' }}
>
<Text style={{ fontSize: 14, fontFamily: 'Nunito_600SemiBold', color: colors.text }}>
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#fff' }}>
{t('blocker.screentime_open_settings_cta')}
</Text>
</TouchableOpacity>
{/* Confirm */}
<TouchableOpacity
onPress={onConfirm}
disabled={saving}

View File

@ -1,4 +1,4 @@
import { useState, useCallback } from 'react';
import { useState, useCallback, useEffect } from 'react';
import {
View,
Text,
@ -23,6 +23,7 @@ type DmConversation = {
partnerName: string;
partnerAvatar: string | null;
lastMessage: string;
lastAttachmentType?: string | null;
lastMessageAt: string;
unreadCount: number;
isOwn: boolean;
@ -77,7 +78,10 @@ function DmItem({ conv, onPress }: { conv: DmConversation; onPress: () => void }
]}
>
{conv.isOwn ? `${t('chat.you')} ` : ''}
{conv.lastMessage}
{conv.lastMessage ||
(conv.lastAttachmentType === 'audio' ? `🎤 ${t('chat.voice_message')}` :
conv.lastAttachmentType === 'image' ? `📷 ${t('chat.photo')}` :
`📎 ${t('chat.media_sent')}`)}
</Text>
{hasUnread && (
<View style={styles.unreadBadge}>
@ -100,6 +104,13 @@ export default function ChatScreen() {
const styles = makeStyles(colors);
const [search, setSearch] = useState('');
const [userRefreshing, setUserRefreshing] = useState(false);
const [debouncedSearch, setDebouncedSearch] = useState('');
// 300ms debounce für User-Suche
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(search.trim()), 300);
return () => clearTimeout(t);
}, [search]);
const {
data: convs = [],
@ -127,6 +138,21 @@ export default function ChatScreen() {
)
: convs;
// Zweite Stufe: User-Suche (nur wenn Suchbegriff ≥ 2 Zeichen)
const {
data: userResults = [],
isFetching: searchingUsers,
} = useQuery<{ id: string; nickname: string; avatar: string | null }[]>({
queryKey: ['user-search', debouncedSearch],
queryFn: () => apiFetch(`/api/users/search?q=${encodeURIComponent(debouncedSearch)}`),
enabled: debouncedSearch.length >= 2,
staleTime: 10_000,
});
// Bereits gechattet? User-Resultate ohne aktive Conversations zeigen
const existingPartnerIds = new Set(convs.map((c) => c.partnerId));
const newUsers = userResults.filter((u) => !existingPartnerIds.has(u.id));
const openDm = useCallback(
(userId: string) => {
router.push(`/dm?userId=${userId}`);
@ -182,7 +208,45 @@ export default function ChatScreen() {
)
}
renderItem={({ item }) => <DmItem conv={item} onPress={() => openDm(item.partnerId)} />}
contentContainerStyle={{ paddingBottom: 100 }}
ListFooterComponent={
debouncedSearch.length >= 2 ? (
<View style={{ paddingBottom: 100 }}>
<View style={styles.newConvHeader}>
<Text style={[styles.newConvLabel, { color: colors.textMuted }]}>
{t('chat.new_conversation')}
</Text>
{searchingUsers && <ActivityIndicator size="small" color={colors.brandOrange} />}
</View>
{newUsers.length === 0 && !searchingUsers ? (
<View style={styles.emptyBox}>
<Text style={styles.emptyText}>{t('chat.no_users_found')}</Text>
</View>
) : (
newUsers.map((u) => (
<TouchableOpacity
key={u.id}
onPress={() => openDm(u.id)}
activeOpacity={0.7}
>
<View style={styles.dmRow}>
<UserAvatar userId={u.id} avatar={u.avatar} nickname={u.nickname} size="md" />
<View style={styles.dmInfo}>
<Text style={styles.dmName} numberOfLines={1}>{u.nickname}</Text>
<Text style={[styles.dmLast, { fontFamily: 'Nunito_400Regular', color: colors.textMuted }]}>
{t('chat.start_conversation')}
</Text>
</View>
<Ionicons name="chevron-forward" size={16} color={colors.textMuted} />
</View>
</TouchableOpacity>
))
)}
</View>
) : (
<View style={{ paddingBottom: 100 }} />
)
}
contentContainerStyle={{ flexGrow: 1 }}
/>
</View>
);
@ -218,7 +282,7 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
emptyBox: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 60,
paddingVertical: 40,
paddingHorizontal: 32,
},
emptyText: {
@ -227,6 +291,23 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
color: colors.textMuted,
marginTop: 12,
},
newConvHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingHorizontal: 16,
paddingTop: 20,
paddingBottom: 8,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.border,
},
newConvLabel: {
fontSize: 12,
fontFamily: 'Nunito_700Bold',
textTransform: 'uppercase',
letterSpacing: 0.5,
flex: 1,
},
dmRow: {
flexDirection: 'row',
alignItems: 'center',

View File

@ -15,6 +15,7 @@ import {
Dimensions,
type FlatList as FlatListType,
} from 'react-native';
import { Audio } from 'expo-av';
import { KeyboardStickyView } from 'react-native-keyboard-controller';
import { Image } from 'expo-image';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
@ -27,6 +28,7 @@ import * as ImagePicker from 'expo-image-picker';
import * as FileSystem from 'expo-file-system/legacy';
import { apiFetch } from '../lib/api';
import { ChatBubble, type ChatMsg, type MessageReaction } from '../components/chat/ChatBubble';
import { VoiceRecordingBar, formatVoiceDuration } from '../components/chat/VoiceRecordingBar';
import { DmChatBackground } from '../components/chat/DmChatBackground';
import { FormSheet } from '../components/FormSheet';
import { useDmRealtime } from '../hooks/useChatRealtime';
@ -67,7 +69,6 @@ export default function DmScreen() {
const { t } = useTranslation();
const router = useRouter();
const insets = useSafeAreaInsets();
const isAndroid = Platform.OS === 'android';
const colors = useColors();
const styles = makeStyles(colors);
const queryClient = useQueryClient();
@ -80,14 +81,11 @@ export default function DmScreen() {
const flatListRef = useRef<FlatListType<ChatMsg>>(null);
// scrollToEnd() auf Android unterschätzt Content-Höhe und stoppt 1 Item
// zu früh. scrollToOffset(999999) wird auf den echten Max-Wert geclampt.
// scrollToEnd() unterschätzt auf Android UND iOS die Content-Höhe und
// stoppt 1 Item zu früh (besonders nach Bild-Load + InputBar-Padding).
// scrollToOffset(999999) wird intern auf den echten Max-Wert geclampt.
const scrollToBottom = useCallback((animated = false) => {
if (Platform.OS === 'android') {
flatListRef.current?.scrollToOffset({ offset: 999999, animated });
} else {
flatListRef.current?.scrollToEnd({ animated });
}
}, []);
const [messages, setMessages] = useState<ChatMsg[]>([]);
const [partner, setPartner] = useState<DmHistoryResponse['partner'] | null>(null);
@ -105,6 +103,15 @@ export default function DmScreen() {
const [infoSheetOpen, setInfoSheetOpen] = useState(false);
const [lightboxUri, setLightboxUri] = useState<string | null>(null);
// Voice recording
const [isVoiceRecording, setIsVoiceRecording] = useState(false);
const [voiceDuration, setVoiceDuration] = useState(0);
const [voiceLevel, setVoiceLevel] = useState(0);
const [voiceTrashFlash, setVoiceTrashFlash] = useState(false);
const voiceRecordingRef = useRef<Audio.Recording | null>(null);
const voiceTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const voiceStartTime = useRef(0);
// Reset aller conversation-spezifischen States wenn userId wechselt (Stack-Reuse)
useEffect(() => {
setMessages([]);
@ -467,10 +474,133 @@ export default function DmScreen() {
setReplyTo({
id: msg.id,
nickname: msg.nickname ?? '?',
content: msg.content?.slice(0, 100) || (msg.attachmentType === 'image' ? 'Bild' : ''),
content: msg.content?.slice(0, 100) ||
(msg.attachmentType === 'image' ? `📷 ${t('chat.photo')}` :
msg.attachmentType === 'audio' ? `🎤 ${t('chat.voice_message')}` : ''),
});
}
async function startVoiceRecording() {
if (isVoiceRecording || sending) return;
const { status } = await Audio.requestPermissionsAsync();
if (status !== 'granted') {
Alert.alert(t('chat.mic_access_title'), t('chat.mic_access_body'));
return;
}
try {
await Audio.setAudioModeAsync({ allowsRecordingIOS: true, playsInSilentModeIOS: true });
const rec = new Audio.Recording();
await rec.prepareToRecordAsync({ ...Audio.RecordingOptionsPresets.HIGH_QUALITY, isMeteringEnabled: true });
await rec.startAsync();
voiceRecordingRef.current = rec;
voiceStartTime.current = Date.now();
setVoiceDuration(0);
setIsVoiceRecording(true);
voiceTimerRef.current = setInterval(async () => {
setVoiceDuration(Math.floor((Date.now() - voiceStartTime.current) / 1000));
try {
const s = await voiceRecordingRef.current?.getStatusAsync();
if (s?.isRecording && s.metering !== undefined) {
setVoiceLevel(Math.max(0, Math.min(1, (s.metering + 60) / 60)));
}
} catch {}
}, 200);
} catch {
await Audio.setAudioModeAsync({ allowsRecordingIOS: false }).catch(() => {});
}
}
async function cancelVoiceRecording() {
const rec = voiceRecordingRef.current;
voiceRecordingRef.current = null;
if (voiceTimerRef.current) { clearInterval(voiceTimerRef.current); voiceTimerRef.current = null; }
setVoiceDuration(0);
setVoiceLevel(0);
setVoiceTrashFlash(true);
setTimeout(() => {
setVoiceTrashFlash(false);
setIsVoiceRecording(false);
}, 350);
try { await rec?.stopAndUnloadAsync(); } catch {}
await Audio.setAudioModeAsync({ allowsRecordingIOS: false });
}
async function stopAndSendVoice() {
if (!isVoiceRecording) return;
if (voiceTimerRef.current) { clearInterval(voiceTimerRef.current); voiceTimerRef.current = null; }
const duration = voiceDuration;
setIsVoiceRecording(false);
setVoiceDuration(0);
setVoiceLevel(0);
const rec = voiceRecordingRef.current;
voiceRecordingRef.current = null;
if (!rec) return;
try { await rec.stopAndUnloadAsync(); } catch {}
await Audio.setAudioModeAsync({ allowsRecordingIOS: false });
const uri = rec.getURI();
if (!uri) return;
await sendVoiceMessage(uri, formatVoiceDuration(duration));
}
async function sendVoiceMessage(uri: string, durationStr: string) {
if (sending) return;
const tempId = `temp-voice-${Date.now()}`;
const optimisticMsg: ChatMsg = {
id: tempId,
userId: myUserId ?? '',
nickname: 'Du',
avatar: null,
content: '',
replyTo: null,
attachmentUrl: uri,
attachmentType: 'audio',
attachmentName: durationStr,
likesCount: 0,
likedByMe: false,
createdAt: new Date().toISOString(),
isOwn: true,
readAt: null,
status: 'pending',
};
setMessages((prev) => [...prev, optimisticMsg]);
setSending(true);
try {
setUploading(true);
const ext = uri.split('.').pop() ?? 'm4a';
const path = `chat/${Date.now()}_${Math.random().toString(36).slice(2, 8)}.${ext}`;
const base64 = await FileSystem.readAsStringAsync(uri, { encoding: FileSystem.EncodingType.Base64 });
const binary = typeof atob === 'function' ? atob(base64) : Buffer.from(base64, 'base64').toString('binary');
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
const { error } = await supabase.storage.from('chat-attachments').upload(path, bytes, {
cacheControl: '3600',
upsert: false,
contentType: 'audio/m4a',
});
setUploading(false);
if (error) throw error;
const { data } = supabase.storage.from('chat-attachments').getPublicUrl(path);
const newMsg = await apiFetch<any>('/api/chat/dm', {
method: 'POST',
body: { receiverId: userId, content: '', attachmentUrl: data.publicUrl, attachmentType: 'audio', attachmentName: durationStr },
});
setMessages((prev) =>
prev.map((m) =>
m.id === tempId
? { ...m, id: newMsg.id, attachmentUrl: newMsg.attachmentUrl, createdAt: newMsg.createdAt, status: 'sent' as const }
: m
)
);
queryClient.invalidateQueries({ queryKey: ['dm-conversations'] });
} catch (err) {
console.error('Voice send failed:', err);
setMessages((prev) => prev.map((m) => m.id === tempId ? { ...m, status: 'failed' as const } : m));
setUploading(false);
} finally {
setSending(false);
}
}
function sameAuthor(a: ChatMsg | undefined, b: ChatMsg | undefined): boolean {
if (!a || !b) return false;
if (a.userId !== b.userId) return false;
@ -606,6 +736,19 @@ export default function DmScreen() {
</TouchableOpacity>
</View>
)}
{isVoiceRecording ? (
<View style={styles.voiceRecBarWrap}>
<VoiceRecordingBar
duration={voiceDuration}
level={voiceLevel}
trashFlash={voiceTrashFlash}
onCancel={cancelVoiceRecording}
onSend={stopAndSendVoice}
sendIcon="checkmark"
accentColor="#007AFF"
/>
</View>
) : (
<View style={styles.inputRow}>
<TouchableOpacity
activeOpacity={0.7}
@ -627,7 +770,7 @@ export default function DmScreen() {
onSubmitEditing={handleSend}
editable={!sending && !uploading}
/>
{(inputText.trim().length > 0 || attachment) && (
{(inputText.trim().length > 0 || attachment) ? (
<TouchableOpacity
style={[styles.sendBtn, (sending || uploading) && styles.sendBtnDisabled]}
onPress={handleSend}
@ -640,8 +783,18 @@ export default function DmScreen() {
<Ionicons name="send" size={16} color="#fff" />
)}
</TouchableOpacity>
) : (
<TouchableOpacity
style={[styles.addBtn, { backgroundColor: colors.surfaceElevated }]}
onPress={startVoiceRecording}
disabled={sending}
activeOpacity={0.7}
>
<Ionicons name="mic" size={20} color={colors.textMuted} />
</TouchableOpacity>
)}
</View>
)}
</View>
</KeyboardStickyView>
@ -899,9 +1052,9 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
paddingTop: 8,
},
addBtn: {
width: 38,
height: 38,
borderRadius: 19,
width: 44,
height: 44,
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
},
@ -915,9 +1068,9 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
maxHeight: 120,
},
sendBtn: {
width: 38,
height: 38,
borderRadius: 19,
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: '#007AFF',
alignItems: 'center',
justifyContent: 'center',
@ -925,5 +1078,11 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
sendBtnDisabled: {
opacity: 0.4,
},
voiceRecBarWrap: {
flexDirection: 'row',
marginHorizontal: 12,
marginTop: 8,
marginBottom: 4,
},
});
}

View File

@ -34,19 +34,13 @@ import { supabase } from '../lib/supabase';
import { useColors } from '../lib/theme';
import { useThemeStore } from '../stores/theme';
import { detectEmotion } from '../lib/lyraResponse';
function formatDuration(s: number): string {
const m = Math.floor(s / 60);
const sec = (s % 60).toString().padStart(2, '0');
return `${m}:${sec}`;
}
import { VoiceRecordingBar, VoiceBars, formatVoiceDuration } from '../components/chat/VoiceRecordingBar';
function formatTimestamp(date: Date): string {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
// ── Loading skeleton ──────────────────────────────────────────────────────────
// Standard-Spinner — kein zweiter Rive-Avatar (der ist bereits im topBar oben).
function LoadingPulse() {
const colors = useColors();
@ -92,50 +86,6 @@ function ThinkingDots() {
);
}
// ── Voice bars ────────────────────────────────────────────────────────────────
function VoiceBars({ count, baseColor, active }: { count: number; baseColor: string; active: boolean }) {
const anims = useRef(Array.from({ length: count }, () => new Animated.Value(3))).current;
const runningRef = useRef(false);
useEffect(() => {
if (active && !runningRef.current) {
runningRef.current = true;
const animations = anims.map((a, i) =>
Animated.loop(
Animated.sequence([
Animated.timing(a, { toValue: 3 + Math.random() * 14, duration: 400 + (i % 5) * 90, useNativeDriver: false }),
Animated.timing(a, { toValue: 3, duration: 400 + (i % 5) * 90, useNativeDriver: false }),
])
)
);
animations.forEach((a) => a.start());
return () => { animations.forEach((a) => a.stop()); runningRef.current = false; };
} else if (!active) {
// Stille: alle Bars auf minimale Höhe zurück
anims.forEach((a) => Animated.timing(a, { toValue: 3, duration: 150, useNativeDriver: false }).start());
runningRef.current = false;
}
}, [active]);
return (
<View style={{ flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-evenly', height: 20 }}>
{anims.map((a, i) => (
<Animated.View
key={i}
style={{
width: active ? 2.5 : 2,
height: active ? a : 2, // Stille: kleine Punkte
borderRadius: 2,
backgroundColor: baseColor,
opacity: active ? 0.75 : 0.4,
}}
/>
))}
</View>
);
}
// ── Message row (Insta-DM style) ──────────────────────────────────────────────
type MessageWithMeta = Message & { timestamp: Date };
@ -497,16 +447,16 @@ export default function CoachScreen() {
async function cancelRecording() {
if (!isRecording) return;
// Kurze rote Flash-Animation bevor der State verschwindet
setTrashFlash(true);
setTimeout(() => setTrashFlash(false), 400);
const rec = recordingRef.current;
recordingRef.current = null;
micHeld.current = false;
stopRecordingTimer();
setTrashFlash(true);
setTimeout(() => {
setTrashFlash(false);
setIsRecording(false);
try {
await recordingRef.current?.stopAndUnloadAsync();
} catch { /* already stopped */ }
recordingRef.current = null;
}, 350);
try { await rec?.stopAndUnloadAsync(); } catch {}
await Audio.setAudioModeAsync({ allowsRecordingIOS: false });
}
@ -689,42 +639,15 @@ export default function CoachScreen() {
{/* Input bar */}
<View style={[styles.inputBar, { paddingBottom: keyboardHeight > 0 ? 8 : Math.max(12, insets.bottom), backgroundColor: colors.bg, borderTopColor: colors.border }]}>
{isRecording ? (
/* ── Instagram-style Recording Bar ─────────────────────── */
<View style={[styles.recordingBar, { backgroundColor: colors.surfaceElevated, borderColor: colors.border }]}>
{/* Trash - links, kurz rot bei Klick */}
<TouchableOpacity
style={[
styles.recSideBtn,
{ backgroundColor: trashFlash ? 'rgba(220,38,38,0.15)' : colors.bg },
]}
onPress={cancelRecording}
activeOpacity={0.7}
>
<Ionicons
name="trash-outline"
size={17}
color={trashFlash ? '#ef4444' : colors.textMuted}
<VoiceRecordingBar
duration={recordingDuration}
level={audioLevel}
trashFlash={trashFlash}
onCancel={cancelRecording}
onSend={onMicUp}
sendIcon="arrow-up"
accentColor={colors.brandOrange}
/>
</TouchableOpacity>
{/* Waveform + Timer - mitte */}
<View style={styles.recCenter}>
<View style={[styles.recLiveDot, { backgroundColor: colors.brandOrange }]} />
<VoiceBars count={22} baseColor={colors.text} active={audioLevel > 0.1} />
<Text style={[styles.recTimer, { color: colors.textMuted }]}>
{formatDuration(recordingDuration)}
</Text>
</View>
{/* Send - rechts */}
<TouchableOpacity
style={[styles.recSideBtn, { backgroundColor: colors.brandOrange }]}
onPress={onMicUp}
activeOpacity={0.8}
>
<Ionicons name="arrow-up" size={18} color="#fff" />
</TouchableOpacity>
</View>
) : isTranscribing ? (
<View style={styles.transcribingRow}>
<Ionicons name="sync" size={16} color={colors.textMuted} />
@ -994,9 +917,9 @@ const styles = StyleSheet.create({
maxHeight: 120,
},
micBtn: {
width: 38,
height: 38,
borderRadius: 19,
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: '#f5f5f5',
alignItems: 'center',
justifyContent: 'center',
@ -1019,40 +942,6 @@ const styles = StyleSheet.create({
sendBtnDisabled: {
opacity: 0.4,
},
recordingBar: {
flex: 1,
height: 44,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: 22,
paddingHorizontal: 6,
},
recSideBtn: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
},
recCenter: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
recLiveDot: {
width: 7,
height: 7,
borderRadius: 3.5,
},
recTimer: {
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
fontVariant: ['tabular-nums'],
minWidth: 32,
},
transcribingRow: {
flex: 1,
flexDirection: 'row',

View File

@ -2,7 +2,7 @@ import { useRef, useState, useEffect } from 'react';
import { View, ScrollView, Text, Alert, Switch, findNodeHandle, UIManager } from 'react-native';
import { useTranslation } from 'react-i18next';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { useRouter, useLocalSearchParams } from 'expo-router';
import { AppHeader } from '../../components/AppHeader';
import { ProfileHeader, type AuthProvider } from '../../components/profile/ProfileHeader';
import { StatsBar } from '../../components/profile/StatsBar';
@ -95,6 +95,16 @@ export default function ProfileScreen() {
const [demographicsExpanded, setDemographicsExpanded] = useState(false);
const { me } = useMe();
const { user } = useAuthStore();
const { openDemo } = useLocalSearchParams<{ openDemo?: string }>();
// Direkt aus DiGA-Milestone-Modal geöffnet → Demographics ausklappen + scrollen
useEffect(() => {
if (openDemo === '1') {
setDemographicsExpanded(true);
setTimeout(() => openDemographics(), 400);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [openDemo]);
const [presenceVisible, setPresenceVisible] = useState<boolean>(true);

View File

@ -0,0 +1,152 @@
import { useEffect, useRef, useState } from 'react';
import { View, Text, TouchableOpacity, Animated } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useMe } from '../hooks/useMe';
import { apiFetch } from '../lib/api';
import { FormSheet } from './FormSheet';
import { useColors } from '../lib/theme';
const MILESTONES = [3, 7, 10] as const;
function storageKey(userId: string, days: number) {
return `@rebreak/diga_milestone_${userId}_${days}`;
}
type DemographicsResp = { birthYear: number | null } & Record<string, unknown>;
export function DiGaMilestoneModal() {
const { t } = useTranslation();
const colors = useColors();
const router = useRouter();
const { me } = useMe();
const [milestone, setMilestone] = useState<number | null>(null);
const scaleAnim = useRef(new Animated.Value(0.8)).current;
// Lean demographics check — only birthYear needed to determine completeness
const { data: demo } = useQuery<DemographicsResp>({
queryKey: ['diga-demo-check'],
queryFn: () => apiFetch('/api/profile/me/demographics'),
enabled: !!me,
staleTime: 60_000,
});
useEffect(() => {
if (!me || demo === undefined) return;
const streak = me.streak ?? 0;
const demographicsComplete = !!(demo?.birthYear);
if (demographicsComplete) return; // already filled → never show
(async () => {
// Find highest milestone reached and not yet shown
for (let i = MILESTONES.length - 1; i >= 0; i--) {
const m = MILESTONES[i];
if (streak < m) continue;
const shown = await AsyncStorage.getItem(storageKey(me.id, m));
if (!shown) {
setMilestone(m);
return;
}
}
})();
}, [me?.id, me?.streak, demo]);
useEffect(() => {
if (milestone !== null) {
Animated.spring(scaleAnim, { toValue: 1, useNativeDriver: true, damping: 14 }).start();
} else {
scaleAnim.setValue(0.8);
}
}, [milestone]);
async function dismiss() {
if (!me || !milestone) return;
await AsyncStorage.setItem(storageKey(me.id, milestone), '1');
setMilestone(null);
}
async function openProfile() {
await dismiss();
router.push('/profile?openDemo=1' as any);
}
if (!milestone) return null;
const badgeColor = milestone >= 10 ? '#f59e0b' : milestone >= 7 ? '#8b5cf6' : colors.brandOrange;
return (
<FormSheet
visible={true}
onClose={dismiss}
title=""
initialHeightPct={0.52}
dismissOnBackdrop
>
<Animated.View style={{ transform: [{ scale: scaleAnim }], paddingHorizontal: 24, paddingTop: 8, paddingBottom: 32, alignItems: 'center', gap: 0 }}>
{/* Milestone badge */}
<View style={{
width: 80, height: 80, borderRadius: 40,
backgroundColor: badgeColor + '18',
alignItems: 'center', justifyContent: 'center',
marginBottom: 16,
}}>
<Text style={{ fontSize: 40 }}>
{milestone >= 10 ? '🏆' : milestone >= 7 ? '🌟' : '🎉'}
</Text>
</View>
<View style={{
backgroundColor: badgeColor + '18',
borderRadius: 20,
paddingHorizontal: 14,
paddingVertical: 5,
marginBottom: 14,
}}>
<Text style={{ fontSize: 13, fontFamily: 'Nunito_700Bold', color: badgeColor }}>
{t('diga_milestone.badge', { days: milestone })}
</Text>
</View>
<Text style={{ fontSize: 22, fontFamily: 'Nunito_800ExtraBold', color: colors.text, textAlign: 'center', marginBottom: 10 }}>
{t('diga_milestone.title', { days: milestone })}
</Text>
<Text style={{ fontSize: 14, fontFamily: 'Nunito_400Regular', color: colors.textMuted, textAlign: 'center', lineHeight: 21, marginBottom: 28 }}>
{t('diga_milestone.body')}
</Text>
{/* Primary CTA */}
<TouchableOpacity
onPress={openProfile}
activeOpacity={0.85}
style={{
width: '100%',
backgroundColor: badgeColor,
borderRadius: 14,
paddingVertical: 15,
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'center',
gap: 8,
marginBottom: 10,
}}
>
<Ionicons name="person-outline" size={17} color="#fff" />
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: '#fff' }}>
{t('diga_milestone.cta')}
</Text>
</TouchableOpacity>
{/* Dismiss */}
<TouchableOpacity onPress={dismiss} activeOpacity={0.7} hitSlop={12}>
<Text style={{ fontSize: 13, fontFamily: 'Nunito_600SemiBold', color: colors.textMuted }}>
{t('diga_milestone.later')}
</Text>
</TouchableOpacity>
</Animated.View>
</FormSheet>
);
}

View File

@ -1,19 +1,158 @@
import { useRef, useState } from 'react';
import { useRef, useState, useEffect, useMemo } from 'react';
import {
View,
Text,
TouchableOpacity,
StyleSheet,
Dimensions,
} from 'react-native';
import { Image } from 'expo-image';
import * as Clipboard from 'expo-clipboard';
import { Ionicons } from '@expo/vector-icons';
import { Audio } from 'expo-av';
import { useTranslation } from 'react-i18next';
import { useColors } from '../../lib/theme';
import { useThemeStore } from '../../stores/theme';
import { UserAvatar } from '../UserAvatar';
import { MessageActionMenu, type AnchorRect } from './MessageActionMenu';
function fmtSec(s: number): string {
const m = Math.floor(s / 60);
const sec = (s % 60).toString().padStart(2, '0');
return `${m}:${sec}`;
}
const SCREEN_W = Dimensions.get('window').width;
function VoiceNoteBubble({ url, duration, isOwn }: { url: string; duration: string; isOwn: boolean }) {
const colors = useColors();
const bubbleColors = useBubbleColors();
const [isPlaying, setIsPlaying] = useState(false);
const [progress, setProgress] = useState(0);
const [currentTime, setCurrentTime] = useState(0);
const [waveWidth, setWaveWidth] = useState(0);
const soundRef = useRef<Audio.Sound | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const totalSeconds = useMemo(() => {
const [m, s] = (duration ?? '0:00').split(':').map(Number);
return (m || 0) * 60 + (s || 0);
}, [duration]);
const barHeights = useMemo(() => {
const seed = url.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0);
// 80 bars, fixed 2dp width via space-between — screen-size-independent thinness
return Array.from({ length: 80 }, (_, i) => {
const a = Math.abs(Math.sin((seed * 0.019 + i) * 2.1));
const b = Math.abs(Math.sin((seed * 0.037 + i) * 3.7));
const c2 = Math.abs(Math.sin((seed * 0.073 + i) * 6.3));
const env = Math.pow(Math.abs(Math.sin((seed * 0.011 + i) * 0.95)), 0.5);
return Math.max(1.5, (a * 0.5 + b * 0.3 + c2 * 0.2) * env * 30);
});
}, [url]);
useEffect(() => {
return () => {
if (pollRef.current) clearInterval(pollRef.current);
soundRef.current?.unloadAsync();
};
}, []);
async function togglePlay() {
if (isPlaying) {
await soundRef.current?.pauseAsync();
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
setIsPlaying(false);
return;
}
try {
if (!soundRef.current) {
await Audio.setAudioModeAsync({ allowsRecordingIOS: false, playsInSilentModeIOS: true });
const { sound } = await Audio.Sound.createAsync({ uri: url }, { shouldPlay: true });
soundRef.current = sound;
sound.setOnPlaybackStatusUpdate((s) => {
if (s.isLoaded && s.didJustFinish) {
setIsPlaying(false);
setProgress(0);
setCurrentTime(0);
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
}
});
} else {
await soundRef.current.playAsync();
}
setIsPlaying(true);
pollRef.current = setInterval(async () => {
const s = await soundRef.current?.getStatusAsync();
if (s?.isLoaded) {
setCurrentTime(Math.floor(s.positionMillis / 1000));
setProgress(s.durationMillis ? s.positionMillis / s.durationMillis : 0);
}
}, 100);
} catch (err) {
console.warn('[VoiceNote] play error:', err);
}
}
const playedCount = Math.floor(progress * barHeights.length);
const DOT_SIZE = 9;
const dotLeft = waveWidth > 0 ? Math.max(0, progress * waveWidth - DOT_SIZE / 2) : 0;
const playBtnBg = isOwn ? 'rgba(0,0,0,0.10)' : 'rgba(0,0,0,0.06)';
const playIconColor = isOwn ? bubbleColors.ownText : colors.text;
// WA-Stil: Bars immer dunkelgrau/schwarz — unabhängig von own/other
const playedBarColor = 'rgba(0,0,0,0.62)';
const unplayedBarColor = 'rgba(0,0,0,0.18)';
const dotColor = '#007AFF';
const durationColor = isOwn ? bubbleColors.ownText + '99' : colors.textMuted;
const displayDuration = isPlaying ? fmtSec(currentTime) : (duration || fmtSec(totalSeconds));
const bubbleW = Math.floor(SCREEN_W * 0.72);
return (
<View style={{ width: bubbleW, paddingVertical: 4, paddingHorizontal: 0 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<TouchableOpacity onPress={togglePlay} activeOpacity={0.7} hitSlop={8}>
<View style={{ width: 36, height: 36, borderRadius: 18, backgroundColor: playBtnBg, alignItems: 'center', justifyContent: 'center' }}>
<Ionicons name={isPlaying ? 'pause' : 'play'} size={16} color={playIconColor} style={{ marginLeft: isPlaying ? 0 : 2 }} />
</View>
</TouchableOpacity>
<View
style={{ flex: 1, height: 32, position: 'relative' }}
onLayout={(e) => setWaveWidth(e.nativeEvent.layout.width)}
>
<View style={{ flexDirection: 'row', alignItems: 'center', height: '100%', justifyContent: 'space-between' }}>
{barHeights.map((h, i) => (
<View
key={i}
style={{ width: 2, height: h, borderRadius: 1, backgroundColor: i < playedCount ? playedBarColor : unplayedBarColor }}
/>
))}
</View>
{waveWidth > 0 && (
<View style={{
position: 'absolute',
top: '50%',
marginTop: -(DOT_SIZE / 2),
left: dotLeft,
width: DOT_SIZE,
height: DOT_SIZE,
borderRadius: DOT_SIZE / 2,
backgroundColor: dotColor,
}} />
)}
</View>
</View>
<Text style={{ fontSize: 11, fontFamily: 'Nunito_600SemiBold', color: durationColor, marginTop: 3, marginLeft: 44, fontVariant: ['tabular-nums'] }}>
{displayDuration}
</Text>
</View>
);
}
export type MessageReaction = { emoji: string; count: number; mine: boolean };
export type ChatMsg = {
@ -71,8 +210,10 @@ function useBubbleColors() {
const isDark = colorScheme === 'dark';
return {
ownBg: isDark ? '#1e4d3a' : '#D1F4CC',
ownAudioBg: isDark ? '#1a4430' : '#C2EDBA',
ownText: isDark ? '#e8f5e2' : '#0a0a0a',
otherBg: isDark ? '#2c2c2e' : '#ffffff',
otherAudioBg: isDark ? '#2c2c2e' : '#ffffff',
otherText: isDark ? '#ffffff' : '#0a0a0a',
replyBarColor: '#25D366',
readColor: '#34B7F1',
@ -112,7 +253,11 @@ export function ChatBubble({
const isImageOnly =
!!msg.attachmentUrl && msg.attachmentType === 'image' && !msg.content && !msg.replyTo;
const replyHasAttachment = msg.replyTo?.attachmentType === 'image';
// isAudioMsg: hat Audio-Attachment (unabhängig von replyTo/content — für Styling)
const isAudioMsg = !!msg.attachmentUrl && msg.attachmentType === 'audio';
const isAudioOnly = isAudioMsg && !msg.content && !msg.replyTo;
const replyHasImage = msg.replyTo?.attachmentType === 'image';
const replyHasAudio = msg.replyTo?.attachmentType === 'audio';
const ownBubbleRadius = {
borderTopLeftRadius: 14,
borderTopRightRadius: isFirstInGroup ? 14 : 4,
@ -127,7 +272,9 @@ export function ChatBubble({
borderBottomRightRadius: 14,
};
const bubbleBg = msg.isOwn ? bubbleColors.ownBg : bubbleColors.otherBg;
const bubbleBg = msg.isOwn
? (isAudioMsg ? bubbleColors.ownAudioBg : bubbleColors.ownBg)
: (isAudioMsg ? bubbleColors.otherAudioBg : bubbleColors.otherBg);
const bubbleText = msg.isOwn ? bubbleColors.ownText : bubbleColors.otherText;
function copyContent() {
@ -176,7 +323,7 @@ export function ChatBubble({
</View>
)}
<View style={[styles.bubbleCol, { alignItems: msg.isOwn ? 'flex-end' : 'flex-start' }]}>
<View style={[styles.bubbleCol, { alignItems: msg.isOwn ? 'flex-end' : 'flex-start' }, isAudioMsg && styles.bubbleColAudio]}>
{showName && !msg.isOwn && isFirstInGroup && (
<Text style={styles.nickname} numberOfLines={1}>
{msg.nickname ?? '?'}
@ -194,6 +341,7 @@ export function ChatBubble({
{ backgroundColor: bubbleBg },
!msg.isOwn && styles.bubbleOtherBorder,
isImageOnly && { padding: 4 },
isAudioMsg && { paddingHorizontal: 6, paddingTop: 6, paddingBottom: 4 },
msg.status === 'pending' && { opacity: 0.6 },
msg.status === 'failed' && { borderWidth: 1, borderColor: '#ef4444' },
]}
@ -230,10 +378,13 @@ export function ChatBubble({
}}
numberOfLines={1}
>
{replyHasAttachment && (
{replyHasImage && (
<Ionicons name="image" size={11} color={colors.textMuted} />
)}
{replyHasAudio && (
<Ionicons name="mic" size={11} color={colors.textMuted} />
)}{' '}
{msg.replyTo.content || (replyHasAttachment ? t('chat.image_attachment') : '…')}
{msg.replyTo.content || (replyHasImage ? t('chat.image_attachment') : replyHasAudio ? `🎤 ${t('chat.voice_message')}` : '…')}
</Text>
</TouchableOpacity>
)}
@ -267,7 +418,15 @@ export function ChatBubble({
</TouchableOpacity>
)}
{msg.attachmentUrl && msg.attachmentType !== 'image' && (
{msg.attachmentUrl && msg.attachmentType === 'audio' && (
<VoiceNoteBubble
url={msg.attachmentUrl}
duration={msg.attachmentName ?? '0:00'}
isOwn={msg.isOwn}
/>
)}
{msg.attachmentUrl && msg.attachmentType !== 'image' && msg.attachmentType !== 'audio' && (
<View
style={{
flexDirection: 'row',
@ -451,6 +610,9 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
bubbleCol: {
maxWidth: '76%',
},
bubbleColAudio: {
maxWidth: '84%',
},
nickname: {
fontSize: 11,
fontFamily: 'Nunito_700Bold',

View File

@ -0,0 +1,129 @@
import { useRef, useEffect } from 'react';
import { View, Text, TouchableOpacity, StyleSheet, Animated } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useColors } from '../../lib/theme';
export function formatVoiceDuration(s: number): string {
const m = Math.floor(s / 60);
const sec = (s % 60).toString().padStart(2, '0');
return `${m}:${sec}`;
}
export function VoiceBars({ count, baseColor, active }: { count: number; baseColor: string; active: boolean }) {
const anims = useRef(Array.from({ length: count }, () => new Animated.Value(3))).current;
const runningRef = useRef(false);
useEffect(() => {
if (active && !runningRef.current) {
runningRef.current = true;
const animations = anims.map((a, i) =>
Animated.loop(
Animated.sequence([
Animated.timing(a, { toValue: 3 + Math.random() * 14, duration: 400 + (i % 5) * 90, useNativeDriver: false }),
Animated.timing(a, { toValue: 3, duration: 400 + (i % 5) * 90, useNativeDriver: false }),
])
)
);
animations.forEach((a) => a.start());
return () => { animations.forEach((a) => a.stop()); runningRef.current = false; };
} else if (!active) {
anims.forEach((a) => Animated.timing(a, { toValue: 3, duration: 150, useNativeDriver: false }).start());
runningRef.current = false;
}
}, [active]);
return (
<View style={{ flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-evenly', height: 20 }}>
{anims.map((a, i) => (
<Animated.View
key={i}
style={{ width: active ? 2.5 : 2, height: active ? a : 2, borderRadius: 2, backgroundColor: baseColor, opacity: active ? 0.75 : 0.4 }}
/>
))}
</View>
);
}
type Props = {
duration: number;
level: number;
trashFlash: boolean;
onCancel: () => void;
onSend: () => void;
/** Icon for the send button. Default: 'arrow-up' (coach). DM uses 'checkmark'. */
sendIcon?: 'arrow-up' | 'checkmark';
/** Accent color for the send button. Defaults to brandOrange. */
accentColor?: string;
};
export function VoiceRecordingBar({ duration, level, trashFlash, onCancel, onSend, sendIcon = 'arrow-up', accentColor }: Props) {
const colors = useColors();
const accent = accentColor ?? colors.brandOrange;
return (
<View style={[styles.bar, { backgroundColor: colors.surfaceElevated, borderColor: colors.border }]}>
<TouchableOpacity
style={[styles.sideBtn, { backgroundColor: trashFlash ? 'rgba(220,38,38,0.15)' : colors.bg }]}
onPress={onCancel}
activeOpacity={0.7}
hitSlop={8}
>
<Ionicons name="trash-outline" size={17} color={trashFlash ? '#ef4444' : colors.textMuted} />
</TouchableOpacity>
<View style={styles.center}>
<View style={[styles.liveDot, { backgroundColor: '#ef4444' }]} />
<VoiceBars count={22} baseColor={colors.text} active={level > 0.1} />
<Text style={[styles.timer, { color: colors.textMuted }]}>
{formatVoiceDuration(duration)}
</Text>
</View>
<TouchableOpacity
style={[styles.sideBtn, { backgroundColor: accent }]}
onPress={onSend}
activeOpacity={0.8}
hitSlop={6}
>
<Ionicons name={sendIcon} size={sendIcon === 'checkmark' ? 20 : 18} color="#fff" />
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
bar: {
flex: 1,
height: 44,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: 22,
paddingHorizontal: 6,
},
sideBtn: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
},
center: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
liveDot: {
width: 7,
height: 7,
borderRadius: 3.5,
},
timer: {
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
fontVariant: ['tabular-nums'],
minWidth: 32,
},
});

View File

@ -314,10 +314,13 @@
"screentime_title": "قفل وقت الشاشة (Layer 3)",
"screentime_desc": "عيّن رمزًا لا يعرفه إلا ReBreak — حتى لا يتمكن أحد من تعطيل وقت الشاشة لتجاوز الحماية.",
"screentime_generate_cta": "توليد رمز",
"screentime_code_label": "رمزك",
"screentime_code_hint": "اذهب إلى الإعدادات → وقت الشاشة → استخدام رمز وقت الشاشة وأدخل هذا الرمز.",
"screentime_open_settings_cta": "فتح الإعدادات → وقت الشاشة",
"screentime_confirm_cta": "لقد عيّنت الرمز",
"screentime_code_label": "رمزك — احفظه",
"screentime_step1": "① اضغط على «فتح وقت الشاشة» أدناه",
"screentime_step2": "② اضغط على «رمز وقت الشاشة»",
"screentime_step3": "③ أدخل الرمز المعروض أعلاه",
"screentime_step_note": "لم يُفعَّل بعد؟ اضغط أولاً على «تفعيل وقت الشاشة».",
"screentime_open_settings_cta": "فتح وقت الشاشة ↗",
"screentime_confirm_cta": "لقد عيّنت الرمز ✓",
"screentime_confirmed_title": "وقت الشاشة مقفل ✓",
"screentime_confirmed_desc": "حمايتك الآن ثلاثية الطبقات. لا يمكن حذف ReBreak بعد الآن دون المرور بفترة التهدئة.",
"layers_a11y_subtitle_active": "إمكانية الوصول نشطة — حماية التطبيق مفعّلة",
@ -347,15 +350,15 @@
"faq4_q": "لماذا لا يمكنني إيقاف الحماية فوراً؟",
"faq4_a": "عندما تشعر بالرغبة غالباً تريد التعطيل السريع — وتندم لاحقاً. تهدئة 24 ساعة تمنحك وقتاً لتهدأ الرغبة. يمكنك إلغاء التهدئة في أي وقت — وتبقى الحماية ببساطة نشطة.",
"faq5_q": "ما هو وضع القفل؟",
"faq5_a": "وضع القفل هو أقوى خيار للحماية. يُثبَّت ReBreak بحيث لا تستطيع حذف التطبيق بنفسك ولا تعطيل الفلتر وحدك. مثالي للمراحل التي تشعر فيها أنك لا تستطيع الوثوق بنفسك وخطر التحايل مرتفع. يُفعَّل عبر خطوة إعداد قصيرة في إعدادات iPhone.",
"faq5_a": "وضع القفل هو أقوى خيار للحماية. يُثبَّت ReBreak بحيث لا تستطيع حذف التطبيق بنفسك ولا تعطيل الفلتر وحدك. مثالي للمراحل التي تشعر فيها أنك لا تستطيع الوثوق بنفسك وخطر التحايل مرتفع. يُفعَّل عبر RebreakMagic — تطبيق صغير على Mac يُكمل الإعداد في حوالي دقيقتين.",
"faq6_q": "كيف أعرف إذا كان iPhone في وضع القفل؟",
"faq6_a": "اذهب إلى الإعدادات ← عام ← معلومات. إذا ظهر في الأعلى \"هذا الـ iPhone خاضع للإشراف وتديره Rebreak GmbH\" — فوضع القفل نشط. إذا لم يظهر شيء: أنت تستخدم الوضع العادي (عبر VPN).",
"faq7_q": "كيف أفعّل وضع القفل؟",
"faq7_a": "تحتاج إلى Safari وبضع دقائق. سنرسل لك التعليمات عبر إشعار — أخبر Lyra فقط حين تكون مستعداً. مهم: بمجرد التفعيل، لا يستطيع رفع القفل إلا الشخص الموثوق (trustee) أو كابل USB + Mac. هذا مقصود — الحماية تعمل لأنها تصمد أمام نبضات التحايل على الذات.",
"faq7_a": "تحتاج مرة واحدة إلى Mac مع أداة RebreakMagic. حمِّلها، وصِّل iPhone عبر USB، افتح RebreakMagic واتبع خطوات الإعداد — خلال حوالي دقيقتين سيكون iPhone في وضع القفل. مهم: بمجرد التفعيل، لا يستطيع رفع القفل إلا الشخص الموثوق (trustee) أو RebreakMagic مرة أخرى على Mac.",
"faq8_q": "كيف أعطّل وضع القفل؟",
"faq8_a": "ليس من iPhone وحده — هذا مبدأ التصميم. الخيارات: 1) شخصك الموثوق لديه التعليمات. 2) Mac + كابل USB + Apple Configurator. 3) وضع استرداد iPhone + إعادة ضبط المصنع (جميع البيانات ستُفقد). قبل ذلك: تحدث مع Lyra. أحياناً يكفي تغيير المنظور.",
"faq8_a": "ليس من iPhone وحده — هذا مبدأ التصميم. الخيارات: 1) شخصك الموثوق. 2) Mac + كابل USB + RebreakMagic (مسار إعادة الضبط داخل الأداة). 3) وضع استرداد iPhone + إعادة ضبط المصنع (جميع البيانات ستُفقد). قبل ذلك: تحدث مع Lyra. أحياناً يكفي تغيير المنظور.",
"faq9_q": "ماذا يحدث إذا فقدت iPhone أو غيّرته؟",
"faq9_a": "إعادة ضبط المصنع أو المسح يزيل وضع القفل مع كل شيء آخر. على iPhone جديد ستحتاج إلى إعداد وضع القفل من جديد. إذا فقدت الجهاز ثم وجدته، يستمر كل شيء كما كان.",
"faq9_a": "إعادة ضبط المصنع أو المسح يزيل وضع القفل مع كل شيء آخر. على iPhone جديد ستحتاج إلى إعداد وضع القفل من جديد — مرة أخرى عبر RebreakMagic. إذا فقدت الجهاز ثم وجدته، يستمر كل شيء كما كان.",
"more_info_title": "تعطيل الحماية",
"cooldown_elapsed_title": "الحماية معطّلة",
"cooldown_elapsed_message": "انتهت التهدئة — تم تعطيل الحماية. يمكنك الآن إيقاف خدمة إمكانية الوصول لـ ReBreak من الإعدادات.",
@ -973,6 +976,12 @@
"placeholder": "اكتب رسالة…",
"you": "أنت: ",
"just_now": "الآن",
"voice_message": "رسالة صوتية",
"photo": "صورة",
"media_sent": "وسائط",
"new_conversation": "محادثة جديدة",
"no_users_found": "لم يُعثر على مستخدمين",
"start_conversation": "أرسل رسالتك الأولى",
"loading": "تحميل…",
"send_failed": "تعذّر إرسال الرسالة.",
"create_group": "إنشاء مجموعة",
@ -1011,7 +1020,9 @@
"send": "إرسال",
"search_placeholder": "البحث في المحادثات…",
"photo_access_title": "الوصول إلى الصور",
"photo_access_body": "يرجى السماح بالوصول إلى الصور في الإعدادات."
"photo_access_body": "يرجى السماح بالوصول إلى الصور في الإعدادات.",
"mic_access_title": "الوصول إلى الميكروفون",
"mic_access_body": "يرجى السماح بالوصول إلى الميكروفون في الإعدادات."
},
"dm": {
"view_profile": "عرض الملف الشخصي",
@ -1363,5 +1374,12 @@
"minutes_ago": "منذ %{minutes} دقيقة",
"hours_ago": "منذ %{hours} ساعة",
"days_ago": "منذ %{days} يوم"
},
"diga_milestone": {
"badge": "اليوم %{days} نظيف",
"title": "%{days} يومًا بدون قمار",
"body": "هذا استثنائي — وأنت تساعدنا في الحصول على اعتماد ReBreak كتطبيق صحة رقمي (DiGA). نحتاج إلى بيانات ديموغرافية مجهولة الهوية. طوعي، دقيقتان فقط.",
"cta": "ملء البيانات",
"later": "ربما لاحقًا"
}
}

View File

@ -277,7 +277,7 @@
"activate_settings_btn": "Einstellungen",
"permission_denied": {
"title": "Schutz wurde abgelehnt",
"body": "iOS hat den Filter nicht installiert, weil im System-Dialog „Nicht erlauben\" getippt wurde. Wir können es nochmal versuchen — diesmal bitte „Erlauben\".",
"body": "iOS hat den Filter nicht installiert, weil im System-Dialog 'Nicht erlauben' getippt wurde. Wir können es nochmal versuchen — diesmal bitte 'Erlauben'.",
"retry_cta": "Erneut versuchen",
"retry_loading": "Einen Moment...",
"settings_cta": "Einstellungen öffnen",
@ -286,7 +286,7 @@
},
"family_controls_error": {
"title": "App-Lock konnte nicht aktiviert werden",
"body": "iOS kann gerade nicht mit dem Bildschirmzeit-Dienst kommunizieren. Das passiert manchmal nach „Nicht erlauben\" oder wenn der Hintergrund-Dienst hängt.",
"body": "iOS kann gerade nicht mit dem Bildschirmzeit-Dienst kommunizieren. Das passiert manchmal nach 'Nicht erlauben' oder wenn der Hintergrund-Dienst hängt.",
"retry_cta": "Erneut versuchen",
"retry_loading": "Einen Moment...",
"settings_cta": "Einstellungen öffnen",
@ -331,10 +331,13 @@
"screentime_title": "Bildschirmzeit sperren (Layer 3)",
"screentime_desc": "Setze einen Code den nur ReBreak kennt — damit kann niemand Bildschirmzeit ausschalten um Deinstallation zu ermöglichen.",
"screentime_generate_cta": "Code generieren",
"screentime_code_label": "Dein Code",
"screentime_code_hint": "Geh zu Einstellungen → Bildschirmzeit → Code festlegen und gib diesen Code ein.",
"screentime_open_settings_cta": "Einstellungen → Bildschirmzeit öffnen",
"screentime_confirm_cta": "Ich habe den Code gesetzt",
"screentime_code_label": "Dein Code — merke ihn dir",
"screentime_step1": "① Tippe 'Bildschirmzeit öffnen'",
"screentime_step2": "② Tippe auf 'Code festlegen'",
"screentime_step3": "③ Gib den Code oben ein",
"screentime_step_note": "Noch nicht aktiv? Erst 'Bildschirmzeit aktivieren' tippen.",
"screentime_open_settings_cta": "Bildschirmzeit öffnen ↗",
"screentime_confirm_cta": "Ich habe den Code gesetzt ✓",
"screentime_confirmed_title": "Bildschirmzeit gesperrt ✓",
"screentime_confirmed_desc": "Dein Schutz ist jetzt dreifach gesichert. ReBreak kann nicht mehr ohne Weiteres deinstalliert werden.",
"layers_a11y_subtitle_active": "Eingabehilfe aktiv — App-Schutz armiert",
@ -364,15 +367,15 @@
"faq4_q": "Warum kann ich den Schutz nicht sofort abschalten?",
"faq4_a": "Wenn du im Drang bist, willst du oft schnell deaktivieren — und es danach bereuen. Der 24-Stunden-Cooldown gibt dir Zeit, den Drang abklingen zu lassen. Du kannst den Cooldown jederzeit abbrechen — der Schutz bleibt dann einfach an.",
"faq5_q": "Was ist der Lock-Modus?",
"faq5_a": "Der Lock-Modus ist die stärkste Schutz-Variante. ReBreak wird so installiert, dass du die App nicht mehr selbst löschen und den Filter nicht selbst abschalten kannst. Ideal wenn du eine Phase hast, in der du dich selbst nicht aushältst und das Bypass-Risiko hoch ist. Aktivierung über eine kleine Konfiguration in den iPhone-Einstellungen.",
"faq5_a": "Der Lock-Modus ist die stärkste Schutz-Variante. ReBreak wird so installiert, dass du die App nicht mehr selbst löschen und den Filter nicht selbst abschalten kannst. Ideal wenn du eine Phase hast, in der du dich selbst nicht aushältst und das Bypass-Risiko hoch ist. Aktivierung über den RebreakMagic — eine kleine Mac-App, die das Setup in ~2 Minuten erledigt.",
"faq6_q": "Wie weiß ich, ob mein iPhone im Lock-Modus ist?",
"faq6_a": "Geh auf Einstellungen → Allgemein → Info. Wenn dort oben steht: Dieses iPhone wird betreut und von Rebreak GmbH verwaltet — ist der Lock-Modus aktiv. Wenn da nichts steht: du nutzt den normalen Modus (VPN-basiert).",
"faq7_q": "Wie aktiviere ich den Lock-Modus?",
"faq7_a": "Du brauchst Safari und ein paar Minuten. Wir schicken dir per Push die Anleitung — sag Lyra Bescheid, wenn du soweit bist. Wichtig: einmal aktiviert, kann nur dein Trustee (oder ein USB-Kabel + Mac) den Lock wieder lösen. Das ist gewollt — der Schutz wirkt, weil er gegen impulsive Selbst-Override-Tendenzen steht.",
"faq7_a": "Du brauchst einmalig einen Mac mit dem RebreakMagic-Tool. Lade es runter, schließe dein iPhone per USB an, öffne RebreakMagic und klick dich durch das Setup — in ~2 Minuten ist dein iPhone im Lock-Modus. Wichtig: einmal aktiviert, kann nur dein Trustee (oder erneut RebreakMagic am Mac) den Lock wieder lösen.",
"faq8_q": "Wie deaktiviere ich den Lock-Modus?",
"faq8_a": "Nicht alleine vom iPhone aus — das ist das Designprinzip. Pfade: 1) Dein Trustee (Vertrauensperson) hat die Anleitung. 2) Mac + USB-Kabel + Apple Configurator. 3) iPhone-Recovery-Mode + Factory-Reset (alle Daten weg). Bevor du das machst: rede mit Lyra. Manchmal hilft schon ein Reframe.",
"faq8_a": "Nicht alleine vom iPhone aus — das ist das Designprinzip. Pfade: 1) Dein Trustee. 2) Mac + USB-Kabel + RebreakMagic (Reset-Flow im Tool). 3) iPhone-Recovery-Mode + Factory-Reset (alle Daten weg). Bevor du das machst: rede mit Lyra. Manchmal hilft schon ein Reframe.",
"faq9_q": "Was passiert, wenn ich mein iPhone verliere oder wechsle?",
"faq9_a": "Beim Factory-Reset oder Wipe verschwindet der Lock-Modus mit allem anderen. Beim neuen iPhone musst du den Lock neu einrichten. Bei Verlust und Wiederfinden läuft alles weiter wie vorher.",
"faq9_a": "Beim Factory-Reset oder Wipe verschwindet der Lock-Modus mit allem anderen. Beim neuen iPhone musst du den Lock neu einrichten — wieder mit dem RebreakMagic. Bei Verlust und Wiederfinden läuft alles weiter wie vorher.",
"more_info_title": "Schutz deaktivieren",
"cooldown_elapsed_title": "Schutz ist aus",
"cooldown_elapsed_message": "Der Cooldown ist abgelaufen — der Schutz wurde deaktiviert. Du kannst den ReBreak-Bedienungshilfe-Dienst jetzt in den Einstellungen ausschalten.",
@ -583,7 +586,7 @@
"feat_android_desc": "Lokaler DNS-Filter — kein externer Server.",
"feat_cooldown_title": "Cooldown-Schutz",
"feat_cooldown_desc": "24h-Reibung bevor du den Schutz deaktivieren kannst.",
"permission_note": "Im nächsten Dialog von iOS / Android: bitte „Erlauben\" wählen."
"permission_note": "Im nächsten Dialog von iOS / Android: bitte 'Erlauben' wählen."
},
"done": {
"cta_primary": "In die App",
@ -1038,6 +1041,12 @@
"placeholder": "Nachricht schreiben…",
"you": "Du: ",
"just_now": "gerade",
"voice_message": "Sprachnachricht",
"photo": "Foto",
"media_sent": "Medien",
"new_conversation": "Neue Unterhaltung",
"no_users_found": "Keine Nutzer gefunden",
"start_conversation": "Schreib eine erste Nachricht",
"loading": "Laden…",
"send_failed": "Nachricht konnte nicht gesendet werden.",
"create_group": "Gruppe erstellen",
@ -1076,7 +1085,9 @@
"send": "Senden",
"search_placeholder": "Konversationen durchsuchen…",
"photo_access_title": "Foto-Zugriff",
"photo_access_body": "Bitte erlaube den Foto-Zugriff in den Einstellungen."
"photo_access_body": "Bitte erlaube den Foto-Zugriff in den Einstellungen.",
"mic_access_title": "Mikrofon-Zugriff",
"mic_access_body": "Bitte erlaube den Mikrofon-Zugriff in den Einstellungen."
},
"dm": {
"view_profile": "Profil anzeigen",
@ -1446,5 +1457,12 @@
"witzig_distraction_01": "Impulskontrolle ist eigentlich nur eine fancy Art zu sagen: du hast dein Zukunfts-Ich angerufen, bevor dein Jetzt-Ich Mist bauen konnte.",
"news_push_tactics_01": "Casinos verschicken Push-Nachrichten bevorzugt freitags ab 18 Uhr und sonntags morgens — gezielt wenn Struktur wegfällt. Der ReBreak-Mailfilter fängt auch die digitale Variante ab.",
"feature_sos_01": "Übrigens: Der SOS-Bereich hat jetzt Minispiele als Ablenkung — Memory, Snake, Tetris. Nicht als Spaß-Feature, sondern weil kurze kognitive Aufgaben den Drang-Loop unterbrechen."
},
"diga_milestone": {
"badge": "Tag %{days} Clean",
"title": "%{days} Tage ohne Glücksspiel",
"body": "Das ist außergewöhnlich — und du hilfst uns, ReBreak als offizielle DiGA (Digitale Gesundheitsanwendung) zuzulassen. Dafür brauchen wir anonyme demografische Daten. Freiwillig, 2 Minuten.",
"cta": "Daten ausfüllen",
"later": "Vielleicht später"
}
}

View File

@ -331,10 +331,13 @@
"screentime_title": "Lock Screen Time (Layer 3)",
"screentime_desc": "Set a code only ReBreak knows — so nobody can disable Screen Time to bypass the uninstall protection.",
"screentime_generate_cta": "Generate code",
"screentime_code_label": "Your code",
"screentime_code_hint": "Go to Settings → Screen Time → Use Screen Time Passcode and enter this code.",
"screentime_open_settings_cta": "Open Settings → Screen Time",
"screentime_confirm_cta": "I've set the code",
"screentime_code_label": "Your code — remember it",
"screentime_step1": "① Tap \"Open Screen Time\" below",
"screentime_step2": "② Tap \"Use Screen Time Passcode\"",
"screentime_step3": "③ Enter the code shown above",
"screentime_step_note": "Not set up yet? Tap \"Turn On Screen Time\" first.",
"screentime_open_settings_cta": "Open Screen Time ↗",
"screentime_confirm_cta": "I've set the code ✓",
"screentime_confirmed_title": "Screen Time locked ✓",
"screentime_confirmed_desc": "Your protection is now triple-layered. ReBreak can no longer be uninstalled without going through the cooldown.",
"layers_a11y_subtitle_active": "Accessibility active — app protection armed",
@ -364,15 +367,15 @@
"faq4_q": "Why can't I turn protection off immediately?",
"faq4_a": "In the moment of urge, you often want to disable fast — and regret it after. The 24-hour cooldown gives you time for the urge to pass. You can cancel the cooldown anytime — protection then simply stays on.",
"faq5_q": "What is Lock Mode?",
"faq5_a": "Lock Mode is the strongest protection option. ReBreak is installed so that you can no longer delete the app yourself or disable the filter on your own. Ideal for phases when you feel you can't trust yourself and the risk of bypassing is high. Activated through a short configuration step in iPhone Settings.",
"faq5_a": "Lock Mode is the strongest protection option. ReBreak is installed so that you can no longer delete the app yourself or disable the filter on your own. Ideal for phases when you feel you can't trust yourself and the risk of bypassing is high. Activated via RebreakMagic — a small Mac app that completes the setup in ~2 minutes.",
"faq6_q": "How do I know if my iPhone is in Lock Mode?",
"faq6_a": "Go to Settings → General → About. If it says \"This iPhone is supervised and managed by Rebreak GmbH\" at the top — Lock Mode is active. If nothing is shown there: you're using normal mode (VPN-based).",
"faq7_q": "How do I activate Lock Mode?",
"faq7_a": "You need Safari and a few minutes. We'll send you the instructions via push notification — just let Lyra know when you're ready. Important: once activated, only your trustee (or a USB cable + Mac) can unlock it again. That's by design — the protection works because it stands against impulsive self-override.",
"faq7_a": "You need a Mac once with the RebreakMagic tool. Download it, plug your iPhone in via USB, open RebreakMagic, and click through the setup — in ~2 minutes your iPhone is in Lock Mode. Important: once activated, only your trustee (or RebreakMagic again at the Mac) can unlock it.",
"faq8_q": "How do I deactivate Lock Mode?",
"faq8_a": "Not from the iPhone alone — that's the design principle. Options: 1) Your trustee has the instructions. 2) Mac + USB cable + Apple Configurator. 3) iPhone Recovery Mode + Factory Reset (all data lost). Before doing that: talk to Lyra. Sometimes a reframe is all it takes.",
"faq8_a": "Not from the iPhone alone — that's the design principle. Options: 1) Your trustee. 2) Mac + USB cable + RebreakMagic (reset flow inside the tool). 3) iPhone Recovery Mode + Factory Reset (all data lost). Before doing that: talk to Lyra. Sometimes a reframe is all it takes.",
"faq9_q": "What happens if I lose my iPhone or switch to a new one?",
"faq9_a": "A factory reset or wipe removes Lock Mode along with everything else. On a new iPhone you'll need to set up Lock Mode again. If you lose and then find your device, everything continues as before.",
"faq9_a": "A factory reset or wipe removes Lock Mode along with everything else. On a new iPhone you'll need to set up Lock Mode again — using RebreakMagic once more. If you lose and then find your device, everything continues as before.",
"more_info_title": "Disable protection",
"cooldown_elapsed_title": "Protection is off",
"cooldown_elapsed_message": "The cooldown has elapsed — protection was disabled. You can now turn off the ReBreak accessibility service in Settings.",
@ -1038,6 +1041,12 @@
"placeholder": "Write a message…",
"you": "You: ",
"just_now": "just now",
"voice_message": "Voice message",
"photo": "Photo",
"media_sent": "Media",
"new_conversation": "New conversation",
"no_users_found": "No users found",
"start_conversation": "Send a first message",
"loading": "Loading…",
"send_failed": "Failed to send message.",
"create_group": "Create group",
@ -1076,7 +1085,9 @@
"send": "Send",
"search_placeholder": "Search conversations…",
"photo_access_title": "Photo access",
"photo_access_body": "Please allow photo access in Settings."
"photo_access_body": "Please allow photo access in Settings.",
"mic_access_title": "Microphone access",
"mic_access_body": "Please allow microphone access in Settings."
},
"dm": {
"view_profile": "View profile",
@ -1429,5 +1440,12 @@
"minutes_ago": "%{minutes} min ago",
"hours_ago": "%{hours} h ago",
"days_ago": "%{days} d ago"
},
"diga_milestone": {
"badge": "Day %{days} Clean",
"title": "%{days} days without gambling",
"body": "That's extraordinary — and you help us get ReBreak officially certified as a DiGA (Digital Health Application). We need anonymous demographic data for that. Voluntary, 2 minutes.",
"cta": "Fill in data",
"later": "Maybe later"
}
}

View File

@ -314,10 +314,13 @@
"screentime_title": "Verrouiller le temps d'écran (Layer 3)",
"screentime_desc": "Définis un code que seul ReBreak connaît — personne ne pourra désactiver le temps d'écran pour contourner la protection.",
"screentime_generate_cta": "Générer un code",
"screentime_code_label": "Ton code",
"screentime_code_hint": "Va dans Réglages → Temps d'écran → Code temps d'écran et entre ce code.",
"screentime_open_settings_cta": "Ouvrir Réglages → Temps d'écran",
"screentime_confirm_cta": "J'ai défini le code",
"screentime_code_label": "Ton code — retiens-le",
"screentime_step1": "① Appuie sur « Ouvrir Temps d'écran » ci-dessous",
"screentime_step2": "② Appuie sur « Code temps d'écran »",
"screentime_step3": "③ Saisis le code affiché ci-dessus",
"screentime_step_note": "Pas encore activé ? Appuie d'abord sur « Activer Temps d'écran ».",
"screentime_open_settings_cta": "Ouvrir Temps d'écran ↗",
"screentime_confirm_cta": "J'ai défini le code ✓",
"screentime_confirmed_title": "Temps d'écran verrouillé ✓",
"screentime_confirmed_desc": "Ta protection est maintenant triple. ReBreak ne peut plus être désinstallé sans passer par la période de refroidissement.",
"kpi_global_label": "Domaines bloqués dans le monde",
@ -345,15 +348,15 @@
"faq4_q": "Pourquoi ne puis-je pas désactiver la protection immédiatement ?",
"faq4_a": "Dans un moment d'impulsion, on veut souvent désactiver rapidement — pour le regretter ensuite. La pause de sécurité de 24 heures vous laisse le temps de laisser passer l'envie. Vous pouvez annuler la pause à tout moment — la protection reste alors simplement active.",
"faq5_q": "Qu'est-ce que le mode Lock ?",
"faq5_a": "Le mode Lock est l'option de protection la plus forte. ReBreak est installé de manière à ce que vous ne puissiez plus supprimer l'application vous-même ni désactiver le filtre seul. Idéal pour les phases où vous sentez que vous ne pouvez pas vous faire confiance et que le risque de contournement est élevé. Activation via une courte étape de configuration dans les Réglages iPhone.",
"faq5_a": "Le mode Lock est l'option de protection la plus forte. ReBreak est installé de manière à ce que vous ne puissiez plus supprimer l'application vous-même ni désactiver le filtre seul. Idéal pour les phases où vous sentez que vous ne pouvez pas vous faire confiance et que le risque de contournement est élevé. Activation via RebreakMagic — une petite application Mac qui réalise la configuration en ~2 minutes.",
"faq6_q": "Comment savoir si mon iPhone est en mode Lock ?",
"faq6_a": "Allez dans Réglages → Général → Informations. Si en haut il est écrit \"Cet iPhone est supervisé et géré par Rebreak GmbH\" — le mode Lock est actif. Si rien n'est affiché : vous utilisez le mode normal (via VPN).",
"faq7_q": "Comment activer le mode Lock ?",
"faq7_a": "Vous avez besoin de Safari et de quelques minutes. Nous vous enverrons les instructions par notification push — dites-le simplement à Lyra quand vous êtes prêt. Important : une fois activé, seul votre trustee (ou un câble USB + Mac) peut lever le verrou. C'est voulu — la protection fonctionne parce qu'elle résiste aux impulsions de contournement.",
"faq7_a": "Vous avez besoin une fois d'un Mac avec l'outil RebreakMagic. Téléchargez-le, branchez votre iPhone en USB, ouvrez RebreakMagic et suivez la configuration — en ~2 minutes votre iPhone est en mode Lock. Important : une fois activé, seul votre trustee (ou RebreakMagic à nouveau sur le Mac) peut lever le verrou.",
"faq8_q": "Comment désactiver le mode Lock ?",
"faq8_a": "Pas depuis l'iPhone seul — c'est le principe de conception. Options : 1) Votre trustee a les instructions. 2) Mac + câble USB + Apple Configurator. 3) Mode de récupération iPhone + réinitialisation d'usine (toutes les données perdues). Avant de faire cela : parlez à Lyra. Parfois un recadrage suffit.",
"faq8_a": "Pas depuis l'iPhone seul — c'est le principe de conception. Options : 1) Votre trustee. 2) Mac + câble USB + RebreakMagic (flux de réinitialisation dans l'outil). 3) Mode de récupération iPhone + réinitialisation d'usine (toutes les données perdues). Avant de faire cela : parlez à Lyra. Parfois un recadrage suffit.",
"faq9_q": "Que se passe-t-il si je perds mon iPhone ou en change ?",
"faq9_a": "Une réinitialisation d'usine ou un wipe supprime le mode Lock avec tout le reste. Sur un nouvel iPhone, vous devrez reconfigurer le mode Lock. En cas de perte puis de retrouvaille, tout continue comme avant.",
"faq9_a": "Une réinitialisation d'usine ou un wipe supprime le mode Lock avec tout le reste. Sur un nouvel iPhone, vous devrez reconfigurer le mode Lock — à nouveau avec RebreakMagic. En cas de perte puis de retrouvaille, tout continue comme avant.",
"more_info_title": "Désactiver la protection",
"cooldown_elapsed_title": "La protection est désactivée",
"cooldown_elapsed_message": "La pause de sécurité est terminée — la protection a été désactivée. Vous pouvez maintenant désactiver le service d'accessibilité ReBreak dans les Réglages.",
@ -960,6 +963,12 @@
"placeholder": "Écrire un message…",
"you": "Vous : ",
"just_now": "à l'instant",
"voice_message": "Message vocal",
"photo": "Photo",
"media_sent": "Média",
"new_conversation": "Nouvelle conversation",
"no_users_found": "Aucun utilisateur trouvé",
"start_conversation": "Envoyer un premier message",
"loading": "Chargement…",
"send_failed": "Impossible d'envoyer le message.",
"create_group": "Créer un groupe",
@ -998,7 +1007,9 @@
"send": "Envoyer",
"search_placeholder": "Rechercher des conversations…",
"photo_access_title": "Accès aux photos",
"photo_access_body": "Veuillez autoriser l'accès aux photos dans les paramètres."
"photo_access_body": "Veuillez autoriser l'accès aux photos dans les paramètres.",
"mic_access_title": "Accès au microphone",
"mic_access_body": "Veuillez autoriser l'accès au microphone dans les paramètres."
},
"dm": {
"view_profile": "Voir le profil",
@ -1347,5 +1358,12 @@
"minutes_ago": "il y a %{minutes} min",
"hours_ago": "il y a %{hours} h",
"days_ago": "il y a %{days} j"
},
"diga_milestone": {
"badge": "Jour %{days} sans jeu",
"title": "%{days} jours sans jeux d'argent",
"body": "C'est extraordinaire — et tu nous aides à faire certifier ReBreak comme DiGA (Application de Santé Numérique). Nous avons besoin de données démographiques anonymes. Volontaire, 2 minutes.",
"cta": "Remplir les données",
"later": "Peut-être plus tard"
}
}

View File

@ -36,6 +36,7 @@ export default defineEventHandler(async (event) => {
partnerName: meta.nickname ?? p?.username ?? "Anonym",
partnerAvatar: meta.avatar ?? null,
lastMessage: lastMsg.content.slice(0, 60),
lastAttachmentType: lastMsg.attachmentType ?? null,
lastMessageAt: lastMsg.createdAt,
isOwn: lastMsg.senderId === user.id,
unreadCount: unreadByPartner[partnerId] ?? 0,

View File

@ -92,10 +92,11 @@ iOS (iPhone & iPad):
iOS Selbstbindungs-Schutz / Lock-Modus (optional, stärkster Modus):
- Auf Anfrage kann ein User sein iPhone in einen "Lock-Modus" stellen: der Schutz (App + Filter) lässt sich dann nicht mehr aus den iOS-Einstellungen entfernen. Das ist die stärkste Selbstbindungsstufe gedacht für Menschen, die wissen dass sie sich im Impulsmoment selbst überlisten würden, wenn der Schutz mit drei Taps deaktivierbar wäre.
- Wie es technisch grob funktioniert (kein Detail an User): Beim Setup wird ein Konfigurations-Profil auf dem Gerät installiert, das App und Filter als "non-removable" markiert. Danach läuft alles autonom auch wenn die App aus dem App-Switcher gewischt wird.
- Wenn ein User wissen will WIE man das einrichtet: verweise sanft auf die In-App-Hilfe-Seite zum Lock-Modus ("In den Einstellungen unter Schutz → Selbstbindung gibt's eine Schritt-für-Schritt-Anleitung — die ist genauer als wir das hier im Chat machen können"). Liste NICHT alle Setup-Schritte selbst auf dafür ist der Chat zu kurz und die Anleitung würde zu viel werden.
- Wenn ein User wissen will WIE man den Lock wieder löst: ruhig und ehrlich erklären im Lock-Modus geht das nicht über die normalen Einstellungen. Es braucht entweder die Vertrauensperson (Trustee) oder einen Mac mit Apple Configurator und Kabel. Das ist gewollt: der Schutz soll genau in dem Moment standhalten, in dem der Impuls ihn loswerden will. Validiere die Frustration zuerst, dann erkläre die Architektur-Entscheidung der User ist der AUTOR dieser Entscheidung, nicht das Opfer.
- Sprache gegenüber dem User: "Lock-Modus", "Selbstbindungs-Schutz", "der stärkste Modus", "Vertrauensperson". NIEMALS technische Begriffe wie "MDM", "Configuration Profile", "NEFilter", "Apple Configurator" außer der User benutzt sie selbst.
- Wie es eingerichtet wird (aktueller, empfohlener Weg): Es gibt eine kleine Mac-Begleit-App, den RebreakMagic. iPhone per USB-Kabel an den Mac, RebreakMagic öffnen, ein Klick etwa 2 Minuten und der Lock läuft. Kein Werks-Reset, kein Datenverlust, keine zusätzliche Apple-Software nötig. Danach läuft alles autonom am iPhone, auch wenn die App aus dem App-Switcher gewischt wird.
- Wenn ein User wissen will WIE man das einrichtet: erwähne den RebreakMagic als 2-Minuten-Setup am Mac via USB-Kabel und verweise sanft auf die In-App-Hilfe ("In den Einstellungen unter Schutz → Selbstbindung findest du die genaue Schritt-für-Schritt-Anleitung — die ist präziser als wir das hier im Chat machen können"). Liste die Setup-Schritte NICHT selbst auf.
- Wenn ein User keinen Mac hat: empathisch "aktuell brauchst du einmalig jemand mit Mac in der Familie oder im Freundeskreis. Wir arbeiten dran, dass das später auch ohne Mac geht." Kein Mac-Shaming. Erinnere, dass der normale Schutz (URL-Filter + VIP-Liste) auch ohne Lock schon stark ist der Lock ist die optionale Extra-Härtung für den schwächsten Moment.
- Wenn ein User wissen will WIE man den Lock wieder löst: ruhig und ehrlich erklären im Lock-Modus geht das nicht über die normalen iOS-Einstellungen. Es gibt drei Wege: die Vertrauensperson (Trustee) kann entsperren, oder der User schließt das iPhone noch einmal per USB-Kabel am Mac an und löst die Bindung im RebreakMagic, oder als allerletzter Notausweg Werks-Reset des iPhones. Das ist gewollt: der Schutz soll genau in dem Moment standhalten, in dem der Impuls ihn loswerden will. Validiere die Frustration zuerst, dann erkläre die Architektur-Entscheidung der User ist der AUTOR dieser Entscheidung, nicht das Opfer.
- Sprache gegenüber dem User: "Lock-Modus", "Selbstbindungs-Schutz", "der stärkste Modus", "Vertrauensperson", "RebreakMagic", "Mac-Begleit-App". NIEMALS technische Begriffe wie "MDM", "supervised", "Configuration Profile", "NEFilter", "Apple Configurator" außer der User benutzt sie selbst.
WENN USER FRAGT OB SEIN LOCK-MODUS AKTIV IST (Selbst-Check):
Es gibt EINEN klaren iPhone-Check, den der User selbst machen kann den nennst du ihm direkt, ohne Drumherum:
@ -113,25 +114,13 @@ Häufiges Missverständnis: User denkt MDM sei nur was für Firmen. Korrigiere s
Wenn der User das Wort "MDM" NICHT benutzt hat, antworte weiterhin in der User-Sprache ("Lock-Modus", "Selbstbindungs-Schutz") und vermeide den Begriff.
WENN USER DEN LOCK-MODUS AKTIVIEREN WILL:
"Den Lock-Modus aktivierst du über zwei kleine Profile, die wir dir bereitstellen — ein paar Klicks in Safari und in den iPhone-Einstellungen. In der App findest du dazu auch eine Schritt-für-Schritt-Anleitung unter Schutz → Selbstbindung. Soll ich dir die Schritte kurz hier zusammenfassen, oder gehst du direkt in die Hilfe-Seite?"
"Den Lock-Modus richtest du mit unserer kleinen Mac-Begleit-App ein, dem RebreakMagic. iPhone per USB-Kabel an den Mac, RebreakMagic öffnen, ein Klick — etwa 2 Minuten und der Lock läuft. Kein Werks-Reset, kein Datenverlust, keine zusätzliche Apple-Software nötig. Die genaue Schritt-für-Schritt-Anleitung findest du in der App unter Schutz → Selbstbindung — die ist präziser als alles was ich dir hier im Chat erklären könnte. Magst du da reingehen, oder hast du noch eine Frage offen?"
Wenn der User "wie installiere ich den Lock-Modus?" / "ja, kurz hier" / "zeig mir die Schritte" sagt: erklär ihm die 2 Schritte konkret. Wichtig er braucht aktuell einen Mac (oder ein anderes Apple-Gerät) zusätzlich zum iPhone, weil Schritt 2 per AirDrop läuft. Formuliere ruhig, Du-Form, schrittweise, keine technischen Begriffe (sag NIEMALS "NEFilter", "MDM-Profil", "mobileconfig" im Tech-Sinn, "cfgutil", "Apple Configurator" außer der User benutzt diese Wörter selbst). Sprich von "Schutz-Profil" und "Lock-Profil".
Wichtig liste die Setup-Schritte NICHT selbst im Chat auf. Der RebreakMagic + die In-App-Hilfe sind die Anlaufstelle. Du darfst grob beschreiben dass es ein 2-Minuten-Setup am Mac via USB ist und dass alles autonom am iPhone weiterläuft, sobald der Klick durch ist.
Antworte etwa so:
Wenn der User keinen Mac hat: validiere kurz ("verstehe, das ist gerade noch eine Hürde") und erinnere, dass er auch ohne Lock-Modus durch URL-Filter + VIP-Liste bereits stark geschützt ist. Frag freundlich ob jemand in Familie/Freundeskreis kurz mit seinem Mac aushelfen könnte wir arbeiten dran, dass das später auch ohne Mac geht.
"Klar, sind 2 Schritte. Du brauchst dafür dein iPhone und kurz einen Mac (oder ein anderes Apple-Gerät) für Schritt 2.
Schritt 1 Schutz-Profil via Safari:
Auf deinem iPhone Safari öffnen. Diese Adresse eintippen: mdm.rebreak.org/enrollment/rebreak-enrollment.mobileconfig. Safari fragt 'Erlauben?' auf Erlauben tippen. Die Einstellungen öffnen sich automatisch (falls nicht: Einstellungen-App ganz oben siehst du 'Profil geladen'). Auf Installieren tippen, deinen iPhone-Code eingeben, Bestätigen.
Schritt 2 Lock-Profil via AirDrop:
Vom Mac (oder anderen Apple-Gerät) nimmst du die Datei rebreak-content-filter-sideload.mobileconfig. Rechtsklick Teilen AirDrop dein iPhone auswählen. Das iPhone fragt 'Empfangen?' auf Annehmen tippen. Einstellungen-App öffnet sich Profil installieren Code Bestätigen. Dieser Schritt macht den Lock nicht-entfernbar.
Fertig. Der Schutz läuft, App und Lock sind nicht mehr aus den iPhone-Einstellungen entfernbar außer dein Trustee hilft dir später, oder du legst dein iPhone mit Kabel an einen Mac."
Hinweis für dich (Lyra) den darfst du erwähnen, wenn der User fragt warum er einen Mac braucht oder wenn er keinen hat: "Aktuell braucht Schritt 2 einen Mac für AirDrop — wir arbeiten dran, dass das bald auch per Email klappt. Stell dir vor, du kriegst die Lock-Datei in deinem Postfach, tippst drauf, fertig. Bis dahin: Mac-User haben kurz Vorteil. Hast du jemand in der Familie oder im Freundeskreis mit Mac, der dir die eine Datei kurz schicken kann?"
Wenn der User sagt er hat keinen Mac und auch niemand im Umfeld: validiere kurz ("verstehe, das ist gerade noch eine Hürde") und biete an, dass er erstmal nur Schritt 1 macht der Schutz läuft dann schon, nur ohne den Lock-Teil. Das ist immer noch besser als gar nichts.
Wenn der User fragt wie er den Lock wieder LÖSEN kann: drei Wege die Vertrauensperson (Trustee) kann entsperren, oder das iPhone noch einmal mit dem RebreakMagic am Mac anschließen und die Bindung dort lösen, oder als allerletzter Notausweg Werks-Reset des iPhones. Validiere die Frustration zuerst.
Android:
- ReBreak arbeitet mit zwei Schutz-Schichten (beide müssen aktiviert sein):
@ -265,7 +254,7 @@ FEATURES (organisch erwähnen, nur wenn passt):
- Gambling-Blocker: blockt Hunderttausende bekannter Glücksspielseiten, system-tief auf iOS, Android via VPN, 6h Cooldown
- iOS-Schutz = zwei Schutzschichten: Schicht 1 ist der "URL-Filter" blockt rund 330.000 bekannte Glücksspielseiten, bevor sie laden (der Hauptschutz im Alltag). Schicht 2 ist die "VIP-Liste" eine vom ReBreak-Team kuratierte Liste der wichtigsten Glücksspielseiten je Land (bis zu 30 pro Land), die als Auffangnetz greift wenn Schicht 1 mal hakt. Die Liste switcht automatisch, wenn der User reist. WICHTIG: Die VIP-Liste ist nicht mehr vom User pflegbar die eigenen Trigger-Seiten laufen separat in Schicht 1 als "Custom-Domains". Wenn ein User fragt ob er wirklich geschützt ist: beruhig ihn warm "falls die eine Schicht mal hakt, fängt die andere auf, du bist doppelt abgesichert". Keine Fachbegriffe, sprich von "zwei Schutzschichten", "deinem Land" und "Auffangnetz".
- Custom-Domains: Der User kann eigene Trigger-Seiten hinzufügen (Pro: 10 Slots, Legend: 20 Slots, refillable, web+mail gemeinsam). Einmal drin, kann er sie nicht selbst löschen bewusst so, als Halt gegen den eigenen Impuls; nur das ReBreak-Team kann eine entfernen. Bei "Limit voll" erklären: vorhandene Domain zur globalen Aufnahme vorschlagen, Slot wird nach Admin-Decision frei. KEIN "Swap"-Mechanismus mehr in der VIP-Liste (gibt's seit dem Country-Pivot nicht mehr).
- Lock-Modus (Selbstbindungs-Schutz, optional, stärkster Modus): Auf Anfrage kann ein User sein iPhone so einrichten, dass App + Filter nicht mehr aus den iOS-Einstellungen entfernbar sind gedacht für Menschen, die wissen dass sie sich im Impulsmoment selbst überlisten würden. Wenn User "wie installiere ich das?": erklär ihm die 2 Schritte konkret (Schritt 1: Schutz-Profil via Safari unter mdm.rebreak.org/enrollment/rebreak-enrollment.mobileconfig Installieren Code Bestätigen. Schritt 2: Lock-Profil per AirDrop vom Mac an iPhone Datei rebreak-content-filter-sideload.mobileconfig AirDrop iPhone Annehmen Installieren Code Bestätigen. Schritt 2 macht den Lock nicht-entfernbar.) Sag dem User auch ehrlich: er braucht aktuell einen Mac (oder anderes Apple-Gerät) für Schritt 2, weil das per AirDrop läuft. Hinweis falls relevant: "Wir arbeiten dran, dass das bald auch per Email klappt — bis dahin haben Mac-User kurz Vorteil." Wenn User "wie deaktiviere ich das wieder?": ruhig erklären im Lock-Modus geht das nur über die Vertrauensperson oder einen Mac mit Kabel; das ist gewollt, der Schutz soll genau dem Impuls standhalten, der ihn loswerden will. Sprache: "Lock-Modus", "Selbstbindung", "Schutz-Profil", "Lock-Profil", "Vertrauensperson" NIEMALS "MDM", "NEFilter", "cfgutil", "Apple Configurator" (außer User benutzt diese Wörter selbst).
- Lock-Modus (Selbstbindungs-Schutz, optional, stärkster Modus): Auf Anfrage kann ein User sein iPhone so einrichten, dass App + Filter nicht mehr aus den iOS-Einstellungen entfernbar sind gedacht für Menschen, die wissen dass sie sich im Impulsmoment selbst überlisten würden. Wenn User "wie installiere ich das?": erklär kurz dass das mit unserer Mac-Begleit-App RebreakMagic geht iPhone per USB-Kabel an den Mac, RebreakMagic öffnen, ein Klick, ca. 2 Minuten und der Lock läuft. Kein Werks-Reset, kein Datenverlust, keine zusätzliche Apple-Software nötig. Für die exakten Schritte verweise auf die In-App-Hilfe unter Schutz Selbstbindung (präziser als hier im Chat) liste die Schritte NICHT selbst auf. Wenn User keinen Mac hat: empathisch "aktuell brauchst du einmalig jemand mit Mac in der Familie oder im Freundeskreis; wir arbeiten dran, dass das später auch ohne Mac geht". Kein Mac-Shaming; erinnere dass der normale Schutz (URL-Filter + VIP-Liste) auch ohne Lock schon stark ist. Wenn User "wie deaktiviere ich das wieder?": drei Wege die Vertrauensperson (Trustee) entsperrt, oder der User schließt das iPhone noch einmal per USB-Kabel mit dem RebreakMagic am Mac an und löst die Bindung dort, oder letzter Notausweg Werks-Reset des iPhones. Das ist gewollt: der Schutz soll genau dem Impuls standhalten, der ihn loswerden will. Sprache: "Lock-Modus", "Selbstbindung", "RebreakMagic", "Mac-Begleit-App", "Vertrauensperson" NIEMALS "MDM", "supervised", "NEFilter", "Configuration Profile", "Apple Configurator", "cfgutil" (außer User benutzt diese Wörter selbst).
- Self-Check Lock-Modus aktiv? Wenn ein User fragt, ob sein Lock-Modus läuft, gib ihm die EINE klare Antwort: "Geh auf Einstellungen → Allgemein → Info. Wenn da oben steht 'Dieses iPhone wird betreut und von Rebreak GmbH verwaltet' — dann läuft der Lock-Modus. Sonst bist du im normalen Schutz-Modus." Nicht ausschmücken.
- Wenn User sich wundert, dass an einer Stelle "Rebreak GmbH" und an anderer "Raynis GmbH" steht: kurz beruhigen "Rebreak GmbH und Raynis GmbH sind dasselbe Team, Raynis ist die Mutterfirma hinter der ReBreak-App. Beides ist legitim."
- Wenn User selbst nach "MDM" fragt und denkt, das sei nur was für Firmen: sanft korrigieren "MDM gibt's in zwei Kontexten: klassisches Firmen-MDM (Arbeitgeber installiert's aufs Diensthandy) und 'Self-Bind' (du installierst es freiwillig auf deinem eigenen iPhone, damit der Schutz besonders fest verankert ist). Bei ReBreak ist es IMMER Self-Bind — niemand zwingt dich, du wählst es selbst. Das ist der ReBreak-Lock-Modus."
@ -276,7 +265,7 @@ FEATURES (organisch erwähnen, nur wenn passt):
- Mail-Schutz (Absender/Betreff scannen, kein Inhalt)
- Community (anonym)
- Ich (Lyra) immer da, ohne Urteil
- Plus für Legend: Voice-Picker (du klingst dann wirklich, mehrere Stimmen wählbar), Multi-Device (3 Geräte iOS+Android+macOS mischbar), Mail-Daemon unbegrenzt (Fair-Use ~10 Konten), Premium-Support, optional zubuchbar der RebReakBinder (macOS-App, ~2-Min-Setup via USB macht ReBreak nicht-löschbar ohne Apple Configurator und ohne Reset).
- Plus für Legend: Voice-Picker (du klingst dann wirklich, mehrere Stimmen wählbar), Multi-Device (3 Geräte iOS+Android+macOS mischbar), Mail-Daemon unbegrenzt (Fair-Use ~10 Konten), Premium-Support, optional zubuchbar der RebreakMagic (macOS-App, ~2-Min-Setup via USB macht ReBreak nicht-löschbar ohne Apple Configurator und ohne Reset).
PLÄNE & PREISE:
{{PLAN_DETAILS}}
@ -290,6 +279,24 @@ PRICING-DISZIPLIN — wichtig:
FEEDBACK & IDEEN AKTIV EINLADEN:
Wenn der User Feedback, Feature-Wünsche, Gedanken zu rebreak teilt: aufrichtig interessiert sein, "notiert, geht direkt ans Team". NIEMALS sagen du kannst kein Feedback weiterleiten es wird automatisch gespeichert und gelesen. Wenn er nach Status fragt: schau im Block "FEEDBACK & IDEEN DIESES USERS" nach.
DiGA-DEMOGRAFIE GELEGENTLICH, KONTEXTUELL, NIE AUFDRINGLICH:
ReBreak arbeitet auf die Zulassung als Digitale Gesundheitsanwendung (DiGA) beim BfArM hin wenn das klappt, könnten Krankenkassen ReBreak erstatten. Dafür helfen anonyme Demografie-Angaben von Nutzern (Geburtsjahr, Geschlecht, Beschäftigungsstatus, Bundesland), die der User freiwillig in seinem Profil pflegen kann.
Du darfst den User SANFT und GELEGENTLICH einladen, diese Angaben in seinem Profil zu ergänzen aber nur unter strengen Bedingungen. ALLE drei müssen erfüllt sein:
1. Im Kontext-Block "[USER-DEMOGRAPHIE …]" fehlen Daten ODER er ist gar nicht vorhanden (heißt: der User hat noch nichts/wenig gepflegt). Sind die Daten schon da, frag NICHT.
2. Der User hat gerade einen positiven, leichten Moment Fortschritt, ein Erfolg, Erleichterung, Lob, gute Laune. NIEMALS in einem schweren Moment, bei Drang, nach einem Rückfall, bei Frust oder Traurigkeit.
3. Es passt natürlich in den Gesprächsfluss und du hast es in DIESEM Gespräch noch nicht angesprochen. Maximal EINMAL pro Unterhaltung. Nicht jede Session.
Wenn du es ansprichst: in deiner Stimme warm, persönlich, als kleine Bitte unter Verbündeten, NIE wie eine Umfrage, NIE werbend, NIE "bitte füll das Formular aus". Danach sofort den Faden des Gesprächs wieder aufnehmen, nicht insistieren. Sagt der User nein oder "später", ist das völlig okay akzeptier es sofort und sprich es im selben Gespräch nicht nochmal an.
Beispiel-Tonalität (DE): "Hey, darf ich dich kurz um was bitten? Wir arbeiten dran, ReBreak offiziell als Gesundheits-App zuzulassen — dann könnten Krankenkassen es irgendwann erstatten. Dafür brauchen wir ein paar anonyme Angaben von Leuten wie dir, ganz freiwillig. Magst du bei Gelegenheit kurz in deinem Profil reinschauen? Kein Stress wenn nicht."
Beispiel-Tonalität (EN): "Hey, can I ask you a small favor? We're working on getting ReBreak officially recognized as a health app — which could mean insurers cover it down the line. For that we rely on a few anonymous details from people like you, totally voluntary. Would you mind taking a quick look in your profile sometime? No worries at all if not."
STRIKT VERBOTEN dabei:
- Demografie NIEMALS aus dem Gespräch extrahieren oder ableiten. Wenn der User beiläufig sein Alter, seinen Beruf oder Wohnort nennt, ist das ein narrativer Gesprächsinhalt KEINE strukturierte DiGA-Angabe. Du speicherst, notierst oder bestätigst sowas NICHT als Profildaten. Die Trennung zwischen Gesprächs-Erzählung und den Profil-Feldern ist absolut.
- Niemals selbst nach Geburtsjahr / Geschlecht / Beruf / Bundesland fragen, um die Lücke zu füllen. Du verweist AUSSCHLIESSLICH aufs Profil-Formular der User trägt das dort selbst ein, oder gar nicht.
- Kein Druck, keine Wiederholung, kein schlechtes Gewissen machen.
BEI AKUTEM DRANG: sanft auf SOS-Hilfe hinweisen, nicht dramatisch. "Die Gambling-Industrie hat genau diesen Moment designed — wir haben auch was designed, das dagegen hilft. Magst du das ausprobieren?" Dann dort nicht weiterplaudern.
BEI ERNSTHAFTEN KRISEN verweise IMMER auf:
@ -349,7 +356,7 @@ Legend (7,99 € / Monat — Stripe-Web-Checkout, kein In-App-Kauf):
- Kann Community-Gruppen gründen (z.B. private Support-Gruppe mit Familie)
- Premium-Lyra (Claude Haiku) + Voice-Picker (mehrere Stimmen wählbar)
- Premium-Support
- Optional zubuchbar: RebReakBinder (macOS-App, ~2-Min-Setup via USB macht die ReBreak-App nicht-löschbar ohne Recovery, ohne Apple Configurator, ohne Reset)`;
- Optional zubuchbar: RebreakMagic (macOS-App, ~2-Min-Setup via USB macht die ReBreak-App nicht-löschbar ohne Recovery, ohne Apple Configurator, ohne Reset)`;
}
const PROVIDER_CONFIG = {

View File

@ -0,0 +1,30 @@
/** GET /api/users/search?q=... — Nickname-Suche für neue DM-Gespräche */
export default defineEventHandler(async (event) => {
const user = await requireUser(event);
const query = getQuery(event);
const q = ((query.q as string) ?? "").trim();
if (!q || q.length < 2) return [];
const db = usePrisma();
const results = await db.profile.findMany({
where: {
id: { not: user.id },
deletedAt: null,
OR: [
{ nickname: { contains: q, mode: "insensitive" } },
{ username: { contains: q, mode: "insensitive" } },
],
},
select: { id: true, nickname: true, username: true, avatar: true },
take: 20,
orderBy: { nickname: "asc" },
});
return results.map((p) => ({
id: p.id,
nickname: p.nickname ?? p.username ?? "Anonym",
avatar: p.avatar ?? null,
}));
});

View File

@ -66,7 +66,11 @@ export async function sendDirectMessage(
"../services/push"
);
const senderName = await getDisplayName(senderId);
const preview = truncatePreview(content || (opts?.attachmentUrl ? "📎 Anhang" : ""));
const preview = truncatePreview(
content ||
(opts?.attachmentType === "audio" ? "🎤 Sprachnachricht" :
opts?.attachmentType === "image" || opts?.attachmentUrl ? "📷 Foto" : "")
);
console.log(`[dm-push] sender=${senderId} receiver=${receiverId} preview="${preview.slice(0, 30)}"`);
await sendChatPush({
receiverId,
@ -195,6 +199,7 @@ export async function getDmConversations(userId: string) {
content: true,
createdAt: true,
readAt: true,
attachmentType: true,
},
});
}