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:
parent
0e4c3787c2
commit
42a8223bfc
@ -15,6 +15,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useColors } from '../lib/theme';
|
import { useColors } from '../lib/theme';
|
||||||
import { useDevicesStore, type UserDevice } from '../stores/devices';
|
import { useDevicesStore, type UserDevice } from '../stores/devices';
|
||||||
import { useProtectedDevicesStore, type ProtectedDevice } from '../stores/protectedDevices';
|
import { useProtectedDevicesStore, type ProtectedDevice } from '../stores/protectedDevices';
|
||||||
|
import { useProtectedDevicesRealtime } from '../hooks/useProtectedDevicesRealtime';
|
||||||
import { useUserPlan } from '../hooks/useUserPlan';
|
import { useUserPlan } from '../hooks/useUserPlan';
|
||||||
import { AppHeader } from '../components/AppHeader';
|
import { AppHeader } from '../components/AppHeader';
|
||||||
import { AddMacSheet } from '../components/devices/AddMacSheet';
|
import { AddMacSheet } from '../components/devices/AddMacSheet';
|
||||||
@ -422,6 +423,8 @@ export default function DevicesScreen() {
|
|||||||
loadProtected();
|
loadProtected();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useProtectedDevicesRealtime();
|
||||||
|
|
||||||
const MAX_PROTECTED_DEVICES = 2;
|
const MAX_PROTECTED_DEVICES = 2;
|
||||||
const activeProtectedCount = protectedDevices.filter((d) => d.status !== 'revoked').length;
|
const activeProtectedCount = protectedDevices.filter((d) => d.status !== 'revoked').length;
|
||||||
const atDeviceLimit = isLegend && activeProtectedCount >= MAX_PROTECTED_DEVICES;
|
const atDeviceLimit = isLegend && activeProtectedCount >= MAX_PROTECTED_DEVICES;
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useRef, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import * as Haptics from 'expo-haptics';
|
import * as Haptics from 'expo-haptics';
|
||||||
@ -16,6 +16,7 @@ import { useColors } from '../../lib/theme';
|
|||||||
import { FormSheet } from '../FormSheet';
|
import { FormSheet } from '../FormSheet';
|
||||||
import { RiveAvatar } from '../RiveAvatar';
|
import { RiveAvatar } from '../RiveAvatar';
|
||||||
import { useProtectedDevicesStore } from '../../stores/protectedDevices';
|
import { useProtectedDevicesStore } from '../../stores/protectedDevices';
|
||||||
|
import { useProtectedDevicesRealtime } from '../../hooks/useProtectedDevicesRealtime';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
|
|
||||||
// TODO lyra-persona: review lyra_intro + step_* body strings for coach tone
|
// TODO lyra-persona: review lyra_intro + step_* body strings for coach tone
|
||||||
@ -45,20 +46,28 @@ export function AddMacSheet({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { enroll, confirmInstalled, enrolling } = useProtectedDevicesStore();
|
const { enroll, enrolling } = useProtectedDevicesStore();
|
||||||
|
|
||||||
const [step, setStep] = useState<Step>(1);
|
const [step, setStep] = useState<Step>(1);
|
||||||
const [label, setLabel] = useState('MacBook Pro');
|
const [label, setLabel] = useState('MacBook Pro');
|
||||||
const [labelError, setLabelError] = useState('');
|
const [labelError, setLabelError] = useState('');
|
||||||
const [enrollResult, setEnrollResult] = useState<{ deviceId: string; downloadUrl: string } | null>(null);
|
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() {
|
function reset() {
|
||||||
setStep(1);
|
setStep(1);
|
||||||
setLabel('MacBook Pro');
|
setLabel('MacBook Pro');
|
||||||
setLabelError('');
|
setLabelError('');
|
||||||
setEnrollResult(null);
|
setEnrollResult(null);
|
||||||
setConfirming(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClose() {
|
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() {
|
function handleDownload() {
|
||||||
if (!enrollResult?.downloadUrl) return;
|
if (!enrollResult?.downloadUrl) return;
|
||||||
Linking.openURL(enrollResult.downloadUrl).catch(() => {});
|
Linking.openURL(enrollResult.downloadUrl).catch(() => {});
|
||||||
@ -141,9 +136,7 @@ export function AddMacSheet({
|
|||||||
{step === 2 && (
|
{step === 2 && (
|
||||||
<Step2OnboardingContent
|
<Step2OnboardingContent
|
||||||
onDownload={handleDownload}
|
onDownload={handleDownload}
|
||||||
onConfirmInstalled={handleConfirmInstalled}
|
|
||||||
onNeedHelp={handleNeedHelp}
|
onNeedHelp={handleNeedHelp}
|
||||||
confirming={confirming}
|
|
||||||
colors={colors}
|
colors={colors}
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
@ -230,16 +223,12 @@ function Step1LabelContent({
|
|||||||
|
|
||||||
function Step2OnboardingContent({
|
function Step2OnboardingContent({
|
||||||
onDownload,
|
onDownload,
|
||||||
onConfirmInstalled,
|
|
||||||
onNeedHelp,
|
onNeedHelp,
|
||||||
confirming,
|
|
||||||
colors,
|
colors,
|
||||||
t,
|
t,
|
||||||
}: {
|
}: {
|
||||||
onDownload: () => void;
|
onDownload: () => void;
|
||||||
onConfirmInstalled: () => void;
|
|
||||||
onNeedHelp: () => void;
|
onNeedHelp: () => void;
|
||||||
confirming: boolean;
|
|
||||||
colors: ReturnType<typeof useColors>;
|
colors: ReturnType<typeof useColors>;
|
||||||
t: (k: string) => string;
|
t: (k: string) => string;
|
||||||
}) {
|
}) {
|
||||||
@ -338,28 +327,36 @@ function Step2OnboardingContent({
|
|||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
{/* Confirm installed */}
|
{/* Pending auto-detect pill */}
|
||||||
<TouchableOpacity
|
<View
|
||||||
onPress={onConfirmInstalled}
|
|
||||||
disabled={confirming}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
style={{
|
style={{
|
||||||
borderWidth: 1.5,
|
|
||||||
borderColor: colors.brandOrange,
|
|
||||||
borderRadius: 14,
|
borderRadius: 14,
|
||||||
paddingVertical: 14,
|
paddingVertical: 14,
|
||||||
alignItems: 'center',
|
paddingHorizontal: 16,
|
||||||
opacity: confirming ? 0.7 : 1,
|
backgroundColor: colors.surfaceElevated,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
gap: 6,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{confirming ? (
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
|
||||||
<ActivityIndicator color={colors.brandOrange} />
|
<ActivityIndicator size="small" color={colors.brandOrange} />
|
||||||
) : (
|
<Text style={{ fontSize: 14, color: colors.text, fontFamily: 'Nunito_600SemiBold', flex: 1 }}>
|
||||||
<Text style={{ fontSize: 15, color: colors.brandOrange, fontFamily: 'Nunito_600SemiBold' }}>
|
{t('devices.waiting_install')}
|
||||||
{t('devices.confirm_installed')}
|
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
</View>
|
||||||
</TouchableOpacity>
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
lineHeight: 17,
|
||||||
|
marginLeft: 30,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('devices.waiting_hint')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* Need help */}
|
{/* Need help */}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
|||||||
72
apps/rebreak-native/hooks/useProtectedDevicesRealtime.ts
Normal file
72
apps/rebreak-native/hooks/useProtectedDevicesRealtime.ts
Normal 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]);
|
||||||
|
}
|
||||||
@ -878,9 +878,12 @@
|
|||||||
"step_3_title": "Standard-Account nutzen",
|
"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_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_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",
|
"download_button": "Profile auf Mac downloaden",
|
||||||
"confirm_installed": "Ich hab's installiert ✓",
|
"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",
|
"need_help": "Brauche Hilfe",
|
||||||
"success_title": "Mac geschützt!",
|
"success_title": "Mac geschützt!",
|
||||||
"success_body": "Du kannst weitere Geräte hinzufügen wenn du willst.",
|
"success_body": "Du kannst weitere Geräte hinzufügen wenn du willst.",
|
||||||
|
|||||||
@ -878,9 +878,12 @@
|
|||||||
"step_3_title": "Use a standard account",
|
"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_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_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",
|
"download_button": "Download profile to Mac",
|
||||||
"confirm_installed": "I've installed it ✓",
|
"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",
|
"need_help": "I need help",
|
||||||
"success_title": "Mac protected!",
|
"success_title": "Mac protected!",
|
||||||
"success_body": "You can add more devices whenever you like.",
|
"success_body": "You can add more devices whenever you like.",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user