diff --git a/.expo/README.md b/.expo/README.md new file mode 100644 index 0000000..ce8c4b6 --- /dev/null +++ b/.expo/README.md @@ -0,0 +1,13 @@ +> Why do I have a folder named ".expo" in my project? + +The ".expo" folder is created when an Expo project is started using "expo start" command. + +> What do the files contain? + +- "devices.json": contains information about devices that have recently opened this project. This is used to populate the "Development sessions" list in your development builds. +- "settings.json": contains the server configuration that is used to serve the application manifest. + +> Should I commit the ".expo" folder? + +No, you should not share the ".expo" folder. It does not contain any information that is relevant for other developers working on the project, it is specific to your machine. +Upon project creation, the ".expo" folder is already added to your ".gitignore" file. diff --git a/.expo/devices.json b/.expo/devices.json new file mode 100644 index 0000000..5efff6c --- /dev/null +++ b/.expo/devices.json @@ -0,0 +1,3 @@ +{ + "devices": [] +} diff --git a/apps/rebreak-magic-mac/Sources/Services/MagicAPIClient.swift b/apps/rebreak-magic-mac/Sources/Services/MagicAPIClient.swift index 683a5fd..8184dc1 100644 --- a/apps/rebreak-magic-mac/Sources/Services/MagicAPIClient.swift +++ b/apps/rebreak-magic-mac/Sources/Services/MagicAPIClient.swift @@ -10,6 +10,7 @@ struct MagicRegistration: Codable { enum MagicDeviceSource: String, Codable { case magic + case locked case protected } @@ -29,12 +30,12 @@ struct MagicDevice: Codable, Identifiable { var resolvedSource: MagicDeviceSource { source ?? .magic } var enrolledDate: Date? { - ISO8601DateFormatter().date(from: magicEnrolledAt) + parseISO(magicEnrolledAt) } var releaseDate: Date? { guard let iso = releaseAvailableAt else { return nil } - return ISO8601DateFormatter().date(from: iso) + return parseISO(iso) } var isReleasing: Bool { @@ -47,10 +48,20 @@ struct MagicReleaseResponse: Codable { let releaseAvailableAt: String var releaseDate: Date? { - ISO8601DateFormatter().date(from: releaseAvailableAt) + parseISO(releaseAvailableAt) } } +/// Parses ISO8601 mit + ohne fractional seconds (Backend sendet `.000Z`-Suffix). +private func parseISO(_ s: String) -> Date? { + let f1 = ISO8601DateFormatter() + f1.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let d = f1.date(from: s) { return d } + let f2 = ISO8601DateFormatter() + f2.formatOptions = [.withInternetDateTime] + return f2.date(from: s) +} + /// User-Profil aus /api/magic/me \u2014 f\u00fcr Hub-Header (Avatar + Nickname). struct MagicUserProfile: Codable { let nickname: String? diff --git a/apps/rebreak-magic-mac/Sources/Views/DeviceHubView.swift b/apps/rebreak-magic-mac/Sources/Views/DeviceHubView.swift index d4a9ba9..af05bf7 100644 --- a/apps/rebreak-magic-mac/Sources/Views/DeviceHubView.swift +++ b/apps/rebreak-magic-mac/Sources/Views/DeviceHubView.swift @@ -339,7 +339,7 @@ private struct HubDeviceRow: View { HStack(spacing: 6) { Text(device.hostname) .font(.callout.bold()) - if device.resolvedSource == .protected { + if device.resolvedSource == .protected || device.resolvedSource == .locked { Text("Native-App") .font(.caption2.bold()) .padding(.horizontal, 6) @@ -362,7 +362,7 @@ private struct HubDeviceRow: View { Spacer() - if device.resolvedSource == .protected { + if device.resolvedSource == .protected || device.resolvedSource == .locked { Text("Verwaltung in der ReBreak-App") .font(.caption2) .foregroundStyle(.tertiary) diff --git a/apps/rebreak-native/CHANGELOG.md b/apps/rebreak-native/CHANGELOG.md index 993d7cc..540511c 100644 --- a/apps/rebreak-native/CHANGELOG.md +++ b/apps/rebreak-native/CHANGELOG.md @@ -1,6 +1,24 @@ # Changelog All notable changes to rebreak-native will be documented in this file. +## v0.3.13 (Build 76 / versionCode 59) — 2026-06-04\n\n### Fixes + +- DM image viewer: opening a shared photo no longer jitters — the image now fills the screen smoothly instead of snapping from a square placeholder to its real size on load +- DM info sheet: tapping a shared image now opens the full-screen viewer on top of the sheet and returns to the sheet when you close it, instead of kicking you back to the DM +- DM header status: the online / typing indicator no longer flickers. "Online" is now held steady through brief presence hiccups (no more rapid switching to "last seen"), and "typing…" stays stable while the other person is composing instead of dropping out on every thinking pause +- Lyra Coach chat: Lyra no longer pulls in leftover content from your last SOS crisis session — the SOS flow and the casual Coach chat are now fully separated. Stray internal prompts and raw text that could surface as chat bubbles are filtered out, and any already-affected chat history cleans itself up the next time you open the Coach +- Chat list: conversations now sort newest-first and re-order live — as soon as a new message or call comes in (from the other person or sent from any of your devices), that chat jumps to the top, instead of the list staying in a fixed order +- Chat list: removed the phone emoji in front of "Audio call" in the conversation preview for a cleaner look +- DM typing indicator: moved out of the header into an Instagram-style in-thread bubble at the bottom of the conversation (partner avatar + animated wave dots). The header now stays on online / last-seen, and the "typing…" bubble auto-scrolls into view\n +## v0.3.13 (Build 76 / versionCode 59) — 2026-06-04\n\n### Fixes + +- DM image viewer: opening a shared photo no longer jitters — the image now fills the screen smoothly instead of snapping from a square placeholder to its real size on load +- DM info sheet: tapping a shared image now opens the full-screen viewer on top of the sheet and returns to the sheet when you close it, instead of kicking you back to the DM +- DM header status: the online / typing indicator no longer flickers. "Online" is now held steady through brief presence hiccups (no more rapid switching to "last seen"), and "typing…" stays stable while the other person is composing instead of dropping out on every thinking pause +- Lyra Coach chat: Lyra no longer pulls in leftover content from your last SOS crisis session — the SOS flow and the casual Coach chat are now fully separated. Stray internal prompts and raw text that could surface as chat bubbles are filtered out, and any already-affected chat history cleans itself up the next time you open the Coach +- Chat list: conversations now sort newest-first and re-order live — as soon as a new message or call comes in (from the other person or sent from any of your devices), that chat jumps to the top, instead of the list staying in a fixed order +- Chat list: removed the phone emoji in front of "Audio call" in the conversation preview for a cleaner look +- DM typing indicator: moved out of the header into an Instagram-style in-thread bubble at the bottom of the conversation (partner avatar + animated wave dots). The header now stays on online / last-seen, and the "typing…" bubble auto-scrolls into view\n ## v0.3.13 (Build 71 / versionCode 54) — 2026-06-04\n\n### Fixes - Voice calls: in-call speaker button now actually routes audio to the phone speaker (instead of being a no-op). The route is re-applied after WebRTC has set up its audio session, so it survives the call-connect transition diff --git a/apps/rebreak-native/NEXT_RELEASE.md b/apps/rebreak-native/NEXT_RELEASE.md deleted file mode 100644 index aec48ff..0000000 --- a/apps/rebreak-native/NEXT_RELEASE.md +++ /dev/null @@ -1,6 +0,0 @@ -### Fixes - -- DM image viewer: opening a shared photo no longer jitters — the image now fills the screen smoothly instead of snapping from a square placeholder to its real size on load -- DM info sheet: tapping a shared image now opens the full-screen viewer on top of the sheet and returns to the sheet when you close it, instead of kicking you back to the DM -- DM header status: the online / typing indicator no longer flickers. "Online" is now held steady through brief presence hiccups (no more rapid switching to "last seen"), and "typing…" stays stable while the other person is composing instead of dropping out on every thinking pause -- Lyra Coach chat: Lyra no longer pulls in leftover content from your last SOS crisis session — the SOS flow and the casual Coach chat are now fully separated. Stray internal prompts and raw text that could surface as chat bubbles are filtered out, and any already-affected chat history cleans itself up the next time you open the Coach diff --git a/apps/rebreak-native/app.config.ts b/apps/rebreak-native/app.config.ts index 91e9a25..04c68c5 100644 --- a/apps/rebreak-native/app.config.ts +++ b/apps/rebreak-native/app.config.ts @@ -36,7 +36,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ ios: { supportsTablet: true, bundleIdentifier: MAIN_BUNDLE, - buildNumber: "73", + buildNumber: "76", // Apple Sign-In Entitlement — Pflicht für expo-apple-authentication nativen // signInAsync()-Flow. Ohne flag generiert Expo's prebuild den // com.apple.developer.applesignin-Entitlement nicht in die .entitlements. @@ -62,7 +62,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ android: { package: "org.rebreak.app", - versionCode: 56, + versionCode: 59, adaptiveIcon: { // Foreground muss in der ~66%-Safe-Zone bleiben (Launcher-Mask clippt den // Außenring) → adaptive-foreground.png ist das Logo auf transparentem diff --git a/apps/rebreak-native/app/(app)/chat.tsx b/apps/rebreak-native/app/(app)/chat.tsx index 3831870..cacb893 100644 --- a/apps/rebreak-native/app/(app)/chat.tsx +++ b/apps/rebreak-native/app/(app)/chat.tsx @@ -11,9 +11,11 @@ import { } from 'react-native'; import { useRouter } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { apiFetch } from '../../lib/api'; +import { supabase } from '../../lib/supabase'; +import { useAuthStore } from '../../stores/auth'; import { AppHeader } from '../../components/AppHeader'; import { UserAvatar } from '../../components/UserAvatar'; import { useColors } from '../../lib/theme'; @@ -79,7 +81,7 @@ function DmItem({ conv, onPress }: { conv: DmConversation; onPress: () => void } > {conv.isOwn ? `${t('chat.you')} ` : ''} {conv.lastMessage || - (conv.lastAttachmentType === 'call' ? `📞 ${t('chat.call_audio')}` : + (conv.lastAttachmentType === 'call' ? t('chat.call_audio') : conv.lastAttachmentType === 'audio' ? t('chat.voice_message') : conv.lastAttachmentType === 'image' ? t('chat.photo') : t('chat.media_sent'))} @@ -103,6 +105,8 @@ export default function ChatScreen() { const router = useRouter(); const colors = useColors(); const styles = makeStyles(colors); + const queryClient = useQueryClient(); + const myUserId = useAuthStore((s) => s.user?.id); const [search, setSearch] = useState(''); const [userRefreshing, setUserRefreshing] = useState(false); const [debouncedSearch, setDebouncedSearch] = useState(''); @@ -123,6 +127,50 @@ export default function ChatScreen() { staleTime: 30_000, }); + // Realtime: bei jeder neuen DM/Anruf (eingehend ODER von mir, auch von einem + // anderen Gerät) die Konversationsliste neu laden → sie re-sortiert sich live + // (neueste zuerst). Anrufe sind Rows in direct_messages (attachment_type=call), + // werden also vom selben Insert-Listener mitgefangen. + useEffect(() => { + if (!myUserId) return; + let channel: ReturnType | null = null; + let cancelled = false; + let reconnectTimer: ReturnType | null = null; + + const bump = () => { + queryClient.invalidateQueries({ queryKey: ['dm-conversations'] }); + }; + + async function subscribe() { + const { data } = await supabase.auth.getSession(); + if (cancelled || !data.session?.access_token) return; + channel = supabase + .channel(`dm-list:${myUserId}:${Date.now()}`) + .on('postgres_changes', { + event: 'INSERT', schema: 'rebreak', table: 'direct_messages', + filter: `receiver_id=eq.${myUserId}`, + }, bump) + .on('postgres_changes', { + event: 'INSERT', schema: 'rebreak', table: 'direct_messages', + filter: `sender_id=eq.${myUserId}`, + }, bump) + .subscribe((status: string) => { + if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') { + if (channel) { supabase.removeChannel(channel); channel = null; } + if (reconnectTimer) clearTimeout(reconnectTimer); + reconnectTimer = setTimeout(() => { if (!cancelled) subscribe(); }, 3000); + } + }); + } + + subscribe(); + return () => { + cancelled = true; + if (reconnectTimer) clearTimeout(reconnectTimer); + if (channel) supabase.removeChannel(channel); + }; + }, [myUserId, queryClient]); + const handleRefresh = useCallback(async () => { setUserRefreshing(true); try { @@ -132,12 +180,17 @@ export default function ChatScreen() { } }, [refetchDms]); + // Newest-first: der Server liefert nach partner_id sortiert (DISTINCT ON), NICHT + // nach Aktualität → hier client-seitig nach lastMessageAt absteigend sortieren. + const sorted = [...convs].sort( + (a, b) => new Date(b.lastMessageAt).getTime() - new Date(a.lastMessageAt).getTime(), + ); const filtered = search.trim() - ? convs.filter((c) => + ? sorted.filter((c) => c.partnerName.toLowerCase().includes(search.toLowerCase()) || c.lastMessage.toLowerCase().includes(search.toLowerCase()), ) - : convs; + : sorted; // Zweite Stufe: User-Suche (nur wenn Suchbegriff ≥ 2 Zeichen) const { diff --git a/apps/rebreak-native/app/dm.tsx b/apps/rebreak-native/app/dm.tsx index 30d393b..f5ff9d7 100644 --- a/apps/rebreak-native/app/dm.tsx +++ b/apps/rebreak-native/app/dm.tsx @@ -35,6 +35,7 @@ import { apiFetch } from '../lib/api'; import { ChatBubble, type ChatMsg, type MessageReaction } from '../components/chat/ChatBubble'; import { VoiceRecordingBar, formatVoiceDuration } from '../components/chat/VoiceRecordingBar'; import { MediaLightbox } from '../components/chat/MediaLightbox'; +import { TypingBubble } from '../components/chat/TypingBubble'; import { FormSheet } from '../components/FormSheet'; import { useDmRealtime } from '../hooks/useChatRealtime'; import { useDmTyping } from '../hooks/useDmTyping'; @@ -322,6 +323,12 @@ export default function DmScreen() { // Typing-Indicator (ephemerer Broadcast, kein DB-Write) const { partnerTyping, setComposing, sendStopTyping } = useDmTyping(myUserId, userId); + // Erscheint der In-Thread-Typing-Bubble (ListFooter), ans Ende scrollen damit + // er sichtbar wird — wie Instagram/WhatsApp. + useEffect(() => { + if (partnerTyping) scrollToBottom(true); + }, [partnerTyping, scrollToBottom]); + // Darf der User den Partner anrufen? (gegenseitiger Follow + callsEnabled). // Steuert Sichtbarkeit des Call-Buttons im Header. const { data: canCallData } = useQuery({ @@ -784,7 +791,7 @@ export default function DmScreen() { - {userId && } + {userId && } @@ -831,6 +838,15 @@ export default function DmScreen() { /> )} keyExtractor={(m) => m.id} + ListFooterComponent={ + partnerTyping ? ( + + ) : null + } contentContainerStyle={{ paddingHorizontal: 0, paddingTop: 12, diff --git a/apps/rebreak-native/components/chat/ChatHeaderStatus.tsx b/apps/rebreak-native/components/chat/ChatHeaderStatus.tsx index e111e33..43852e2 100644 --- a/apps/rebreak-native/components/chat/ChatHeaderStatus.tsx +++ b/apps/rebreak-native/components/chat/ChatHeaderStatus.tsx @@ -1,13 +1,10 @@ -import { useEffect, useRef } from 'react'; -import { Text, View, Animated, Easing } from 'react-native'; +import { Text } from 'react-native'; import { useTranslation } from 'react-i18next'; import { useOnlineUsers } from '../../hooks/useOnlineUsers'; import { useLastSeenBatch } from '../../hooks/useLastSeenBatch'; type Props = { userId: string; - /** Partner tippt gerade → überschreibt Online/Last-Seen mit „schreibt …". */ - typing?: boolean; }; const STATUS_COLOR = '#a3a3a3'; @@ -20,38 +17,7 @@ function formatLastSeen(ts: string, t: (key: string, opts?: Record { - const loops = dots.map((d, i) => - Animated.loop( - Animated.sequence([ - Animated.delay(i * 160), - Animated.timing(d, { toValue: 1, duration: 320, easing: Easing.inOut(Easing.ease), useNativeDriver: true }), - Animated.timing(d, { toValue: 0.3, duration: 320, easing: Easing.inOut(Easing.ease), useNativeDriver: true }), - Animated.delay((dots.length - 1 - i) * 160), - ]), - ), - ); - loops.forEach((l) => l.start()); - return () => loops.forEach((l) => l.stop()); - }, [dots]); - - return ( - - {dots.map((d, i) => ( - - ))} - - ); -} - -export function ChatHeaderStatus({ userId, typing }: Props) { +export function ChatHeaderStatus({ userId }: Props) { const { t } = useTranslation(); // DM-Header zeigt den ECHTEN Presence-Status des Partners (wie WhatsApp) — // NICHT die following-gated `isOnline`-Variante aus dem Feed/Profil. Wer dir @@ -61,17 +27,6 @@ export function ChatHeaderStatus({ userId, typing }: Props) { const online = onlineUserIds.has(userId); const lastSeenMap = useLastSeenBatch(online ? [] : [userId]); - if (typing) { - return ( - - - {t('presence.typing')} - - - - ); - } - if (online) { // User-Wunsch: „Online"-Text zeigen, aber NICHT grün (Dot im Avatar reicht // als Farb-Signal). Neutraler `textMuted`-Grau-Ton. diff --git a/apps/rebreak-native/components/chat/TypingBubble.tsx b/apps/rebreak-native/components/chat/TypingBubble.tsx new file mode 100644 index 0000000..c573e9c --- /dev/null +++ b/apps/rebreak-native/components/chat/TypingBubble.tsx @@ -0,0 +1,123 @@ +import { useEffect, useRef } from 'react'; +import { View, Animated, Easing, StyleSheet } from 'react-native'; +import { UserAvatar } from '../UserAvatar'; +import { useColors } from '../../lib/theme'; +import { useThemeStore } from '../../stores/theme'; + +/** + * In-Thread Typing-Indicator (Instagram-Style): Partner-Avatar links + graue + * Bubble mit drei wellenartig auf-/abspringenden Punkten. Erscheint als + * ListFooter unter der letzten Nachricht, solange der Partner tippt. + * + * Bubble-Styling spiegelt die eingehende ChatBubble (cleanBg): hellgrau im + * Light-Mode (#EFEFF1), dunkel im Dark-Mode (#2c2c2e). + */ +function WaveDots({ color }: { color: string }) { + const dots = useRef([new Animated.Value(0), new Animated.Value(0), new Animated.Value(0)]).current; + + useEffect(() => { + const loops = dots.map((d, i) => + Animated.loop( + Animated.sequence([ + Animated.delay(i * 140), + Animated.timing(d, { toValue: 1, duration: 300, easing: Easing.inOut(Easing.ease), useNativeDriver: true }), + Animated.timing(d, { toValue: 0, duration: 300, easing: Easing.inOut(Easing.ease), useNativeDriver: true }), + Animated.delay((dots.length - 1 - i) * 140), + ]), + ), + ); + loops.forEach((l) => l.start()); + return () => loops.forEach((l) => l.stop()); + }, [dots]); + + return ( + + {dots.map((d, i) => ( + + ))} + + ); +} + +export function TypingBubble({ + userId, + avatar, + nickname, +}: { + userId: string | null; + avatar: string | null; + nickname: string; +}) { + const colors = useColors(); + const colorScheme = useThemeStore((s) => s.colorScheme); + const bubbleBg = colorScheme === 'dark' ? '#2c2c2e' : '#EFEFF1'; + + return ( + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + paddingHorizontal: 10, + marginTop: 8, + alignItems: 'flex-end', + }, + avatarSlot: { + width: 32, + marginRight: 6, + justifyContent: 'flex-end', + }, + bubble: { + paddingHorizontal: 14, + height: 34, + justifyContent: 'center', + borderTopLeftRadius: 14, + borderTopRightRadius: 14, + borderBottomLeftRadius: 4, + borderBottomRightRadius: 14, + shadowColor: '#000', + shadowOpacity: 0.08, + shadowRadius: 3, + shadowOffset: { width: 0, height: 1 }, + elevation: 1, + }, + bubbleBorder: { + borderWidth: StyleSheet.hairlineWidth, + borderColor: 'rgba(0,0,0,0.06)', + }, + dotsRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + dot: { + width: 7, + height: 7, + borderRadius: 3.5, + }, +}); diff --git a/apps/rebreak-native/lib/callkit.ts b/apps/rebreak-native/lib/callkit.ts index ed691db..cc29075 100644 --- a/apps/rebreak-native/lib/callkit.ts +++ b/apps/rebreak-native/lib/callkit.ts @@ -68,8 +68,19 @@ export async function setupCallKeep(): Promise { */ export function callIdToUuid(callId: string): string { // CallId hat Format: "-", z.B. "1717491600000-abc12345" - // Wir bauen daraus eine deterministische UUID v4-Form via simple Hex-Padding. - const clean = callId.replace(/[^a-z0-9]/gi, '').toLowerCase().padEnd(32, '0').slice(0, 32); + // CallKit/CXProvider braucht **valides Hex-UUID** (0-9a-f) — sonst native + // crash beim startCall(). Wir mappen jedes Zeichen der callId auf einen + // Hex-Digit via charCode-modulo. Deterministic. + const hex: string[] = []; + for (let i = 0; i < callId.length && hex.length < 32; i++) { + const code = callId.charCodeAt(i); + // 2 Hex-Digits pro Zeichen → genug Material für 32 Hex-Zeichen + hex.push(((code >> 4) & 0xf).toString(16)); + if (hex.length < 32) hex.push((code & 0xf).toString(16)); + } + while (hex.length < 32) hex.push('0'); + const clean = hex.join('').slice(0, 32); + // UUID v4 Format: xxxxxxxx-xxxx-4xxx-Yxxx-xxxxxxxxxxxx (Y ∈ {8,9,a,b}) return `${clean.slice(0, 8)}-${clean.slice(8, 12)}-4${clean.slice(13, 16)}-8${clean.slice(17, 20)}-${clean.slice(20, 32)}`; } diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakContentFilter/Info.plist b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakContentFilter/Info.plist index 4569c31..a744651 100644 --- a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakContentFilter/Info.plist +++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakContentFilter/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString 0.3.13 CFBundleVersion - 73 + 76 NSExtension NSExtensionPointIdentifier diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/Info.plist b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/Info.plist index 45976eb..e89310d 100644 --- a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/Info.plist +++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString 0.3.13 CFBundleVersion - 73 + 76 NSExtension NSExtensionPointIdentifier diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilterExtension/Info.plist b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilterExtension/Info.plist index 3a525a4..d7ab593 100644 --- a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilterExtension/Info.plist +++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilterExtension/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString 0.3.13 CFBundleVersion - 73 + 76 EXAppExtensionAttributes EXExtensionPointIdentifier diff --git a/apps/rebreak-native/stores/call.ts b/apps/rebreak-native/stores/call.ts index cb2056a..e4d4ee4 100644 --- a/apps/rebreak-native/stores/call.ts +++ b/apps/rebreak-native/stores/call.ts @@ -369,19 +369,28 @@ export const useCallStore = create((set, get) => { // ─── Callee: Klingeln empfangen ─────────────────────────────────────── receiveIncoming: (callId, from) => { - // Schon im Gespräch? → ignorieren (MVP: kein call-waiting). - if (get().status !== 'idle') return; + // Schon im Gespräch / oder bereits am Klingeln mit derselben callId? + // → ignorieren (dedup: Realtime + VoIP-Push können beide feuern). + const cur = get(); + if (cur.status !== 'idle') { + if (cur.status === 'incoming' && cur.callId === callId) { + clog('receiveIncoming dedup (already incoming for', callId, ')'); + } + return; + } clog('receiveIncoming from', from.id, 'callId', callId); currentRole = 'callee'; loggedCallId = null; set({ status: 'incoming', peer: from, callId, muted: false, speaker: false, startedAt: null, endReason: null }); // CallKit-/ConnectionService-UI hochziehen — das zeigt nativen Call-Screen - // über Lockscreen, sogar wenn die App im Background ist. + // über Lockscreen, sogar wenn die App im Background ist. RNCallKeep + // dedupliziert intern via UUID, also safe wenn AppDelegate's + // reportNewIncomingCall (VoIP-Push-Pfad) schon dieselbe UUID gemeldet hat. try { callkit.displayIncomingCall(callId, from.nickname || 'ReBreak'); } catch {} - // Klingelton für den Empfänger. InCallManager.start() muss VOR - // startRingtone laufen damit iOS die AVAudioSession korrekt aktiviert. - try { inCall().start({ media: 'audio', auto: false }); } catch (e: any) { clog('inCall start (callee) failed', e?.message ?? e); } - try { inCall().startRingtone('_DEFAULT_', undefined, undefined, 30); } catch (e: any) { clog('startRingtone failed', e?.message ?? e); } + // CallKit (iOS) + ConnectionService (Android) spielen ihren eigenen + // Ringtone — KEIN InCallManager.startRingtone() hier, sonst doppeltes + // Klingeln. InCallManager.start() bleibt aus demselben Grund weg; der + // CallKit-Pfad aktiviert die AVAudioSession selbst. }, acceptCall: async () => { diff --git a/apps/rebreak-native/tmp/.deploy-runtimes b/apps/rebreak-native/tmp/.deploy-runtimes index fd98c91..cd4b52b 100644 --- a/apps/rebreak-native/tmp/.deploy-runtimes +++ b/apps/rebreak-native/tmp/.deploy-runtimes @@ -50,9 +50,15 @@ Building Release AAB (gradlew bundleRelease)|321 Validating IPA (App-Store Connect)|75 Uploading zu App-Store Connect (TestFlight)|80 Building Release AAB (gradlew bundleRelease)|269 -Building xcarchive|265 -Exporting Ad-Hoc IPA|19 -Exporting App-Store IPA|26 Validating IPA (App-Store Connect)|72 Uploading zu App-Store Connect (TestFlight)|87 Building Release AAB (gradlew bundleRelease)|299 +Validating IPA (App-Store Connect)|93 +Uploading zu App-Store Connect (TestFlight)|126 +Building Release AAB (gradlew bundleRelease)|522 +Building xcarchive|285 +Exporting Ad-Hoc IPA|20 +Exporting App-Store IPA|28 +Validating IPA (App-Store Connect)|70 +Uploading zu App-Store Connect (TestFlight)|86 +Building Release AAB (gradlew bundleRelease)|491 diff --git a/backend/server/services/push.ts b/backend/server/services/push.ts index 81e5c92..19a94b1 100644 --- a/backend/server/services/push.ts +++ b/backend/server/services/push.ts @@ -175,8 +175,10 @@ export async function sendCallRingPush(payload: CallRingPushPayload): Promise(); for (const t of tokens) { if (t.platform === "ios" && t.voipToken) { + voipHandledTokenIds.add(t.id); void sendVoIPPush({ voipToken: t.voipToken, callId: payload.callId, @@ -189,10 +191,13 @@ export async function sendCallRingPush(payload: CallRingPushPayload): Promise Marketing SPA (statisch, /var/www/marketing-staging) +# /api/* -> Backend Nitro (127.0.0.1:3016) +# /webhook -> Webhook-Listener (127.0.0.1:9000) +# /api/sse -> Backend Nitro SSE (lange Timeouts, kein Buffering) +# +# Deploy Marketing: scripts/deploy-marketing.sh +# Deploy Backend: git push -> webhook -> scripts/deploy.sh + server { listen 80; server_name staging.rebreak.org; @@ -25,6 +36,8 @@ server { add_header X-XSS-Protection "1; mode=block"; add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"; + # --- Backend-Routen (gehen VOR der Marketing-Location, weil spezifischer) --- + location /webhook { proxy_pass http://127.0.0.1:9000/webhook; proxy_http_version 1.1; @@ -51,7 +64,7 @@ server { chunked_transfer_encoding on; } - location / { + location /api/ { proxy_pass http://127.0.0.1:3016; proxy_http_version 1.1; proxy_set_header Host $host; @@ -65,4 +78,20 @@ server { proxy_connect_timeout 60s; client_max_body_size 50M; } + + # --- Marketing SPA (statisch, Nuxt generate output) --- + + root /var/www/marketing-staging; + index index.html; + + # Long-cache fuer hashed assets (_nuxt/*, fonts, images) + location /_nuxt/ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + location / { + try_files $uri $uri/ $uri.html /index.html; + } } diff --git a/scripts/deploy-marketing.sh b/scripts/deploy-marketing.sh new file mode 100755 index 0000000..c18ae65 --- /dev/null +++ b/scripts/deploy-marketing.sh @@ -0,0 +1,64 @@ +#!/bin/bash +# deploy-marketing.sh -- Local-Build + rsync der Marketing-SPA nach staging.rebreak.org +# +# Pattern: Nuxt generate -> .output/public/ -> rsync auf Hetzner -> /var/www/marketing-staging +# +# Usage: +# ./scripts/deploy-marketing.sh # build + deploy staging +# DRY_RUN=1 ./scripts/deploy-marketing.sh # nur rsync-dry-run, kein write +# +# Voraussetzungen auf Server (einmalig): +# sudo mkdir -p /var/www/marketing-staging +# sudo chown -R $USER:www-data /var/www/marketing-staging +# sudo cp ops/nginx/staging.rebreak.org.conf /etc/nginx/sites-available/ +# sudo nginx -t && sudo systemctl reload nginx + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +MARKETING_DIR="$REPO_ROOT/apps/marketing" + +# Server-Settings (anpassen wenn anderer Host/User) +SSH_HOST="${MARKETING_SSH_HOST:-root@49.13.55.22}" +REMOTE_DIR="${MARKETING_REMOTE_DIR:-/var/www/marketing-staging}" + +log() { echo "[deploy-marketing] $(date '+%H:%M:%S') $*"; } + +log "=== Marketing Deploy gestartet ===" +log "Repo: $REPO_ROOT" +log "Source: $MARKETING_DIR" +log "Target: $SSH_HOST:$REMOTE_DIR" + +# 1. Build (statische Generierung) +log "Step 1: nuxt generate..." +cd "$MARKETING_DIR" +pnpm install --frozen-lockfile +pnpm generate + +PUBLIC_DIR="$MARKETING_DIR/.output/public" +[[ -d "$PUBLIC_DIR" ]] || { + echo "FEHLER: $PUBLIC_DIR existiert nicht nach generate" >&2 + exit 1 +} +[[ -f "$PUBLIC_DIR/index.html" ]] || { + echo "FEHLER: $PUBLIC_DIR/index.html fehlt" >&2 + exit 1 +} + +log "Build ok ($(du -sh "$PUBLIC_DIR" | cut -f1))" + +# 2. rsync nach Server +RSYNC_FLAGS=(-az --delete --info=progress2) +if [[ "${DRY_RUN:-0}" == "1" ]]; then + RSYNC_FLAGS+=(--dry-run) + log "DRY_RUN aktiv -- nur Simulation" +fi + +log "Step 2: rsync nach $SSH_HOST:$REMOTE_DIR ..." +rsync "${RSYNC_FLAGS[@]}" \ + "$PUBLIC_DIR/" \ + "$SSH_HOST:$REMOTE_DIR/" + +log "=== Marketing Deploy erfolgreich ===" +log "Test: curl -I https://staging.rebreak.org/" diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..0e6371f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,4 @@ +{ + "compilerOptions": {}, + "extends": "expo/tsconfig.base" +}