feat(native): auto-detect Mac activation via Supabase Realtime

Replaces the manual "I've installed it" button in AddMacSheet with an
auto-advancing waiting-pill. As soon as the backend flips status from
pending → active (triggered by the DoH handshake from the AdGuard
watcher), the sheet jumps to the success step automatically.

- useProtectedDevicesRealtime hook subscribes to rebreak.protected_devices
  UPDATE events for the current user, with auto-reconnect on CHANNEL_ERROR
- AddMacSheet listens only while in step 2 (download/install)
- devices.tsx keeps a list-level subscription so the table refreshes even
  if the user dismissed the sheet before activation
- i18n: waiting_install / waiting_hint / activated_toast (DE + EN)
This commit is contained in:
chahinebrini 2026-05-15 22:41:25 +02:00
parent 0e4c3787c2
commit 42a8223bfc
5 changed files with 120 additions and 42 deletions

View File

@ -15,6 +15,7 @@ import { useTranslation } from 'react-i18next';
import { useColors } from '../lib/theme';
import { useDevicesStore, type UserDevice } from '../stores/devices';
import { useProtectedDevicesStore, type ProtectedDevice } from '../stores/protectedDevices';
import { useProtectedDevicesRealtime } from '../hooks/useProtectedDevicesRealtime';
import { useUserPlan } from '../hooks/useUserPlan';
import { AppHeader } from '../components/AppHeader';
import { AddMacSheet } from '../components/devices/AddMacSheet';
@ -422,6 +423,8 @@ export default function DevicesScreen() {
loadProtected();
}, []);
useProtectedDevicesRealtime();
const MAX_PROTECTED_DEVICES = 2;
const activeProtectedCount = protectedDevices.filter((d) => d.status !== 'revoked').length;
const atDeviceLimit = isLegend && activeProtectedCount >= MAX_PROTECTED_DEVICES;

View File

@ -8,7 +8,7 @@ import {
TextInput,
View,
} from 'react-native';
import { useRef, useState } from 'react';
import { useCallback, useState } from 'react';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import * as Haptics from 'expo-haptics';
@ -16,6 +16,7 @@ import { useColors } from '../../lib/theme';
import { FormSheet } from '../FormSheet';
import { RiveAvatar } from '../RiveAvatar';
import { useProtectedDevicesStore } from '../../stores/protectedDevices';
import { useProtectedDevicesRealtime } from '../../hooks/useProtectedDevicesRealtime';
import { useRouter } from 'expo-router';
// TODO lyra-persona: review lyra_intro + step_* body strings for coach tone
@ -45,20 +46,28 @@ export function AddMacSheet({
const { t } = useTranslation();
const colors = useColors();
const router = useRouter();
const { enroll, confirmInstalled, enrolling } = useProtectedDevicesStore();
const { enroll, enrolling } = useProtectedDevicesStore();
const [step, setStep] = useState<Step>(1);
const [label, setLabel] = useState('MacBook Pro');
const [labelError, setLabelError] = useState('');
const [enrollResult, setEnrollResult] = useState<{ deviceId: string; downloadUrl: string } | null>(null);
const [confirming, setConfirming] = useState(false);
const handleActivated = useCallback(() => {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {});
setStep(3);
}, []);
useProtectedDevicesRealtime(
step === 2 ? handleActivated : undefined,
step === 2,
);
function reset() {
setStep(1);
setLabel('MacBook Pro');
setLabelError('');
setEnrollResult(null);
setConfirming(false);
}
function handleClose() {
@ -86,20 +95,6 @@ export function AddMacSheet({
}
}
async function handleConfirmInstalled() {
if (!enrollResult) return;
setConfirming(true);
try {
await confirmInstalled(enrollResult.deviceId);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {});
setStep(3);
} catch {
Alert.alert(t('common.error'), t('common.unknown_error'));
} finally {
setConfirming(false);
}
}
function handleDownload() {
if (!enrollResult?.downloadUrl) return;
Linking.openURL(enrollResult.downloadUrl).catch(() => {});
@ -141,9 +136,7 @@ export function AddMacSheet({
{step === 2 && (
<Step2OnboardingContent
onDownload={handleDownload}
onConfirmInstalled={handleConfirmInstalled}
onNeedHelp={handleNeedHelp}
confirming={confirming}
colors={colors}
t={t}
/>
@ -230,16 +223,12 @@ function Step1LabelContent({
function Step2OnboardingContent({
onDownload,
onConfirmInstalled,
onNeedHelp,
confirming,
colors,
t,
}: {
onDownload: () => void;
onConfirmInstalled: () => void;
onNeedHelp: () => void;
confirming: boolean;
colors: ReturnType<typeof useColors>;
t: (k: string) => string;
}) {
@ -338,28 +327,36 @@ function Step2OnboardingContent({
</Text>
</TouchableOpacity>
{/* Confirm installed */}
<TouchableOpacity
onPress={onConfirmInstalled}
disabled={confirming}
activeOpacity={0.7}
{/* Pending auto-detect pill */}
<View
style={{
borderWidth: 1.5,
borderColor: colors.brandOrange,
borderRadius: 14,
paddingVertical: 14,
alignItems: 'center',
opacity: confirming ? 0.7 : 1,
paddingHorizontal: 16,
backgroundColor: colors.surfaceElevated,
borderWidth: 1,
borderColor: colors.border,
gap: 6,
}}
>
{confirming ? (
<ActivityIndicator color={colors.brandOrange} />
) : (
<Text style={{ fontSize: 15, color: colors.brandOrange, fontFamily: 'Nunito_600SemiBold' }}>
{t('devices.confirm_installed')}
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
<ActivityIndicator size="small" color={colors.brandOrange} />
<Text style={{ fontSize: 14, color: colors.text, fontFamily: 'Nunito_600SemiBold', flex: 1 }}>
{t('devices.waiting_install')}
</Text>
)}
</TouchableOpacity>
</View>
<Text
style={{
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
lineHeight: 17,
marginLeft: 30,
}}
>
{t('devices.waiting_hint')}
</Text>
</View>
{/* Need help */}
<TouchableOpacity

View File

@ -0,0 +1,72 @@
import { useEffect, useRef } from "react";
import { supabase } from "../lib/supabase";
import type { RealtimeChannel } from "@supabase/supabase-js";
import { useProtectedDevicesStore } from "../stores/protectedDevices";
type OnActivated = (deviceId: string) => void;
export function useProtectedDevicesRealtime(
onActivated?: OnActivated,
enabled: boolean = true,
) {
const onActivatedRef = useRef<OnActivated | undefined>(onActivated);
onActivatedRef.current = onActivated;
useEffect(() => {
if (!enabled) return;
let channel: RealtimeChannel | null = null;
let cancelled = false;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
async function subscribe() {
const { data } = await supabase.auth.getSession();
const session = data.session;
if (!session?.access_token || cancelled) return;
const userId = session.user.id;
channel = supabase
.channel(`protected:${userId}:${Date.now()}`)
.on(
"postgres_changes",
{
event: "UPDATE",
schema: "rebreak",
table: "protected_devices",
filter: `user_id=eq.${userId}`,
},
(payload: any) => {
const updated = payload.new;
useProtectedDevicesStore.getState().load();
if (updated?.status === "active" && payload.old?.status === "pending") {
onActivatedRef.current?.(updated.id);
}
},
)
.subscribe((status) => {
if (status === "CHANNEL_ERROR" || status === "TIMED_OUT") {
cleanup();
if (reconnectTimer) clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(() => {
if (!cancelled) subscribe();
}, 3000);
}
});
}
function cleanup() {
if (channel) {
supabase.removeChannel(channel);
channel = null;
}
}
subscribe();
return () => {
cancelled = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
cleanup();
};
}, [enabled]);
}

View File

@ -878,9 +878,12 @@
"step_3_title": "Standard-Account nutzen",
"step_3_body": "Wichtig: arbeite auf dem Mac mit einem Standard-Account, nicht Admin. Wenn du ein Notfall-Bedürfnis hast, das Profile zu entfernen, brauchst du das Admin-Passwort — das idealerweise jemand anderes hat (Partnerin, Freund, jemand dem du vertraust). Das ist die eigentliche Schutzschicht.",
"step_4_title": "Fertig",
"step_4_body": "Sobald du installiert hast, klick \"Ich hab's installiert\" — dann zähl ich den Mac als geschütztes Gerät.",
"step_4_body": "Sobald du auf dem Mac im Browser oder in einer App eine Webseite öffnest, erkennen wir das automatisch und aktivieren den Schutz.",
"download_button": "Profile auf Mac downloaden",
"confirm_installed": "Ich hab's installiert ✓",
"waiting_install": "Warte auf Profile-Installation auf deinem Mac…",
"waiting_hint": "Sobald du im Browser oder einer App eine Webseite öffnest, aktivieren wir automatisch dein Gerät.",
"activated_toast": "Mac verbunden!",
"need_help": "Brauche Hilfe",
"success_title": "Mac geschützt!",
"success_body": "Du kannst weitere Geräte hinzufügen wenn du willst.",

View File

@ -878,9 +878,12 @@
"step_3_title": "Use a standard account",
"step_3_body": "Important: use a standard account on the Mac, not admin. If you ever have an urgent need to remove the profile, you'll need the admin password — ideally held by someone you trust (partner, friend). That's the real protection layer.",
"step_4_title": "Done",
"step_4_body": "Once you've installed it, tap \"I've installed it\" — then I'll count the Mac as a protected device.",
"step_4_body": "Once installed, just open any website in a browser or app on your Mac — we'll detect it automatically and activate protection.",
"download_button": "Download profile to Mac",
"confirm_installed": "I've installed it ✓",
"waiting_install": "Waiting for profile installation on your Mac…",
"waiting_hint": "As soon as you open a website in a browser or app, we'll automatically activate your device.",
"activated_toast": "Mac connected!",
"need_help": "I need help",
"success_title": "Mac protected!",
"success_body": "You can add more devices whenever you like.",