fix(calls): no duplicate incoming-call notifications

- backend: skip Expo alert push to iOS devices that already received VoIP push
  (CallKit + banner = double ring)
- native: receiveIncoming no longer triggers InCallManager.startRingtone —
  CallKit/ConnectionService play their own ring. Dedup if same callId
  arrives twice (Realtime + VoIP-Push race).
This commit is contained in:
chahinebrini 2026-06-04 18:28:00 +02:00
parent 92ad4c93b5
commit fb2d90b947
22 changed files with 405 additions and 81 deletions

13
.expo/README.md Normal file
View File

@ -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.

3
.expo/devices.json Normal file
View File

@ -0,0 +1,3 @@
{
"devices": []
}

View File

@ -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?

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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<typeof supabase.channel> | null = null;
let cancelled = false;
let reconnectTimer: ReturnType<typeof setTimeout> | 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 {

View File

@ -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() {
</Text>
<Ionicons name="chevron-forward" size={15} color={colors.textMuted} />
</View>
{userId && <ChatHeaderStatus userId={userId} typing={partnerTyping} />}
{userId && <ChatHeaderStatus userId={userId} />}
</View>
</TouchableOpacity>
@ -831,6 +838,15 @@ export default function DmScreen() {
/>
)}
keyExtractor={(m) => m.id}
ListFooterComponent={
partnerTyping ? (
<TypingBubble
userId={userId ?? null}
avatar={partner?.avatar ?? null}
nickname={partner?.nickname ?? '?'}
/>
) : null
}
contentContainerStyle={{
paddingHorizontal: 0,
paddingTop: 12,

View File

@ -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<string, unkno
return t('presence.days_ago', { days: Math.floor(diff / 86_400_000) });
}
/** Drei pulsierende Punkte (WA/Insta-Style) neben dem „schreibt"-Text. */
function TypingDots() {
const dots = useRef([new Animated.Value(0.3), new Animated.Value(0.3), new Animated.Value(0.3)]).current;
useEffect(() => {
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 (
<View style={{ flexDirection: 'row', alignItems: 'center', marginLeft: 4, gap: 2 }}>
{dots.map((d, i) => (
<Animated.View
key={i}
style={{ width: 3, height: 3, borderRadius: 1.5, backgroundColor: STATUS_COLOR, opacity: d }}
/>
))}
</View>
);
}
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 (
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: STATUS_COLOR }}>
{t('presence.typing')}
</Text>
<TypingDots />
</View>
);
}
if (online) {
// User-Wunsch: „Online"-Text zeigen, aber NICHT grün (Dot im Avatar reicht
// als Farb-Signal). Neutraler `textMuted`-Grau-Ton.

View File

@ -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 (
<View style={styles.dotsRow}>
{dots.map((d, i) => (
<Animated.View
key={i}
style={[
styles.dot,
{
backgroundColor: color,
opacity: d.interpolate({ inputRange: [0, 1], outputRange: [0.35, 1] }),
transform: [{ translateY: d.interpolate({ inputRange: [0, 1], outputRange: [0, -4] }) }],
},
]}
/>
))}
</View>
);
}
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 (
<View style={styles.row}>
<View style={styles.avatarSlot}>
<UserAvatar userId={userId} avatar={avatar} nickname={nickname} size="sm" showOnlineIndicator={false} />
</View>
<View
style={[
styles.bubble,
{ backgroundColor: bubbleBg },
colorScheme !== 'dark' && styles.bubbleBorder,
]}
>
<WaveDots color={colors.textMuted} />
</View>
</View>
);
}
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,
},
});

View File

@ -68,8 +68,19 @@ export async function setupCallKeep(): Promise<void> {
*/
export function callIdToUuid(callId: string): string {
// CallId hat Format: "<timestamp>-<random8>", 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)}`;
}

View File

@ -19,7 +19,7 @@
<key>CFBundleShortVersionString</key>
<string>0.3.13</string>
<key>CFBundleVersion</key>
<string>73</string>
<string>76</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>

View File

@ -19,7 +19,7 @@
<key>CFBundleShortVersionString</key>
<string>0.3.13</string>
<key>CFBundleVersion</key>
<string>73</string>
<string>76</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>

View File

@ -19,7 +19,7 @@
<key>CFBundleShortVersionString</key>
<string>0.3.13</string>
<key>CFBundleVersion</key>
<string>73</string>
<string>76</string>
<key>EXAppExtensionAttributes</key>
<dict>
<key>EXExtensionPointIdentifier</key>

View File

@ -369,19 +369,28 @@ export const useCallStore = create<CallState>((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 () => {

View File

@ -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

View File

@ -175,8 +175,10 @@ export async function sendCallRingPush(payload: CallRingPushPayload): Promise<vo
// Läuft parallel zum regulären Push. Wenn voipToken NULL ist (Android,
// alte iOS-Builds ohne PushKit-Setup) wird das Device hier übersprungen
// und fällt auf den normalen Lockscreen-Banner zurück.
const voipHandledTokenIds = new Set<string>();
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<vo
}
// ─── 2) Reguläre Expo-Pushes (Android FCM + iOS-Fallback) ────────────
// Wichtig: iOS-Devices die schon einen VoIP-Push bekommen haben werden
// hier ÜBERSPRUNGEN — sonst doppeltes Klingeln (CallKit + Banner).
const messages: ExpoPushMessage[] = [];
const validTokenIds: string[] = [];
for (const t of tokens) {
if (voipHandledTokenIds.has(t.id)) continue;
if (!Expo.isExpoPushToken(t.token)) {
await db.pushToken
.update({ where: { id: t.id }, data: { enabled: false } })

View File

@ -78,6 +78,16 @@ exec infisical run \
[[ -n "${ADGUARD_BASE_URL:-}" ]] && export NITRO_ADGUARD_BASE_URL="$ADGUARD_BASE_URL"
[[ -n "${ADGUARD_USER:-}" ]] && export NITRO_ADGUARD_USER="$ADGUARD_USER"
[[ -n "${ADGUARD_PASSWORD:-}" ]] && export NITRO_ADGUARD_PASSWORD="$ADGUARD_PASSWORD"
# Voice-Calls (coturn) — runtimeConfig.turn* override.
[[ -n "${TURN_HOST:-}" ]] && export NITRO_TURN_HOST="$TURN_HOST"
[[ -n "${TURN_SECRET:-}" ]] && export NITRO_TURN_SECRET="$TURN_SECRET"
[[ -n "${TURN_REALM:-}" ]] && export NITRO_TURN_REALM="$TURN_REALM"
# VoIP-Push (APNs PushKit) — voip-push.ts liest direkt process.env.APNS_VOIP_*.
# Müssen NICHT auf NITRO_* gemappt werden, aber must be exported so child shell sieht sie.
export APNS_VOIP_P12_PATH="${APNS_VOIP_P12_PATH:-}"
export APNS_VOIP_P12_PASSWORD="${APNS_VOIP_P12_PASSWORD:-}"
export APNS_VOIP_TOPIC="${APNS_VOIP_TOPIC:-}"
export APNS_VOIP_PRODUCTION="${APNS_VOIP_PRODUCTION:-}"
exec '"$NODE_BIN"' '"$INDEX_MJS"'
'

View File

@ -1,3 +1,14 @@
# staging.rebreak.org -- Marketing SPA als Root, Backend-API unter /api/, /webhook, /api/sse
#
# Layout post-marketing-deploy:
# / -> 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;
}
}

64
scripts/deploy-marketing.sh Executable file
View File

@ -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/"

4
tsconfig.json Normal file
View File

@ -0,0 +1,4 @@
{
"compilerOptions": {},
"extends": "expo/tsconfig.base"
}