Relay pairing offer v2 (E2EE) + host connections
This commit is contained in:
@@ -5,7 +5,7 @@ import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
|
||||
import { Link2 } from "lucide-react-native";
|
||||
import { useDaemonRegistry, type HostProfile } from "@/contexts/daemon-registry-context";
|
||||
import { normalizeHostPort } from "@/utils/daemon-endpoints";
|
||||
import { DaemonConnectionTestError, testDaemonEndpointConnection } from "@/utils/test-daemon-connection";
|
||||
import { DaemonConnectionTestError, probeDaemonEndpoint } from "@/utils/test-daemon-connection";
|
||||
import { AdaptiveModalSheet } from "./adaptive-modal-sheet";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
@@ -151,7 +151,7 @@ export interface AddHostModalProps {
|
||||
|
||||
export function AddHostModal({ visible, onClose, onSaved }: AddHostModalProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { addDaemon } = useDaemonRegistry();
|
||||
const { upsertDirectConnection } = useDaemonRegistry();
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
|
||||
@@ -198,11 +198,11 @@ export function AddHostModal({ visible, onClose, onSaved }: AddHostModalProps) {
|
||||
setIsSaving(true);
|
||||
setErrorMessage("");
|
||||
|
||||
await testDaemonEndpointConnection(endpoint);
|
||||
|
||||
const profile = await addDaemon({
|
||||
const { serverId } = await probeDaemonEndpoint(endpoint);
|
||||
const profile = await upsertDirectConnection({
|
||||
serverId,
|
||||
endpoint,
|
||||
label: label.trim(),
|
||||
endpoints: [endpoint],
|
||||
});
|
||||
|
||||
onSaved?.(profile);
|
||||
@@ -223,7 +223,7 @@ export function AddHostModal({ visible, onClose, onSaved }: AddHostModalProps) {
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [addDaemon, endpointRaw, handleClose, isMobile, isSaving, label, onSaved]);
|
||||
}, [endpointRaw, handleClose, isMobile, isSaving, label, onSaved, upsertDirectConnection]);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet title="Direct connection" visible={visible} onClose={handleClose} testID="add-host-modal">
|
||||
|
||||
@@ -28,7 +28,17 @@ export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
|
||||
<ScreenHeader
|
||||
left={
|
||||
<>
|
||||
<Pressable onPress={toggleAgentList} style={styles.menuButton}>
|
||||
<Pressable
|
||||
onPress={toggleAgentList}
|
||||
style={styles.menuButton}
|
||||
testID="menu-button"
|
||||
nativeID="menu-button"
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={isOpen ? "Close menu" : "Open menu"}
|
||||
accessibilityState={{ expanded: isOpen }}
|
||||
>
|
||||
<MenuIcon size={isMobile ? 20 : 16} color={menuIconColor} />
|
||||
</Pressable>
|
||||
{title && (
|
||||
|
||||
@@ -4,38 +4,63 @@ import {
|
||||
type HostProfile,
|
||||
} from "@/contexts/daemon-registry-context";
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl, extractHostPortFromWebSocketUrl } from "@/utils/daemon-endpoints";
|
||||
import {
|
||||
buildDaemonWebSocketUrl,
|
||||
buildRelayWebSocketUrl,
|
||||
} from "@/utils/daemon-endpoints";
|
||||
import { probeDaemonEndpoint } from "@/utils/test-daemon-connection";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ActiveConnection } from "@/contexts/daemon-connections-context";
|
||||
|
||||
function buildCandidateUrls(daemon: HostProfile): string[] {
|
||||
const endpoints = daemon.endpoints ?? [];
|
||||
const sessionId = daemon.relay?.sessionId ?? null;
|
||||
const relayEndpoint = daemon.relay?.endpoint ?? null;
|
||||
const lastKnownGood =
|
||||
typeof daemon.metadata?.lastKnownGoodEndpoint === "string"
|
||||
? (daemon.metadata.lastKnownGoodEndpoint as string)
|
||||
: null;
|
||||
type Candidate = {
|
||||
connectionId: string;
|
||||
url: string;
|
||||
activeConnection: ActiveConnection;
|
||||
daemonPublicKeyB64?: string;
|
||||
};
|
||||
|
||||
const out: string[] = [];
|
||||
const push = (url: string) => {
|
||||
if (!out.includes(url)) out.push(url);
|
||||
};
|
||||
function sortConnectionsByPreference<T extends { id: string }>(
|
||||
connections: T[],
|
||||
preferredId: string | null
|
||||
): T[] {
|
||||
if (!preferredId) return connections;
|
||||
const idx = connections.findIndex((c) => c.id === preferredId);
|
||||
if (idx === -1) return connections;
|
||||
return [connections[idx]!, ...connections.slice(0, idx), ...connections.slice(idx + 1)];
|
||||
}
|
||||
|
||||
const isLastKnownRelay = !!relayEndpoint && lastKnownGood === relayEndpoint;
|
||||
const directEndpoints = endpoints;
|
||||
function buildCandidates(host: HostProfile): Candidate[] {
|
||||
const preferred = host.preferredConnectionId ?? null;
|
||||
|
||||
if (sessionId && relayEndpoint && isLastKnownRelay) {
|
||||
push(buildRelayWebSocketUrl({ endpoint: relayEndpoint, sessionId }));
|
||||
} else if (lastKnownGood) {
|
||||
push(buildDaemonWebSocketUrl(lastKnownGood));
|
||||
const direct = sortConnectionsByPreference(
|
||||
host.connections.filter((c) => c.type === "direct"),
|
||||
preferred
|
||||
);
|
||||
const relay = sortConnectionsByPreference(
|
||||
host.connections.filter((c) => c.type === "relay"),
|
||||
preferred
|
||||
);
|
||||
|
||||
const out: Candidate[] = [];
|
||||
|
||||
for (const conn of direct) {
|
||||
out.push({
|
||||
connectionId: conn.id,
|
||||
url: buildDaemonWebSocketUrl(conn.endpoint),
|
||||
activeConnection: { type: "direct", endpoint: conn.endpoint, display: conn.endpoint },
|
||||
});
|
||||
}
|
||||
|
||||
for (const endpoint of directEndpoints) {
|
||||
push(buildDaemonWebSocketUrl(endpoint));
|
||||
}
|
||||
|
||||
if (sessionId && relayEndpoint) {
|
||||
push(buildRelayWebSocketUrl({ endpoint: relayEndpoint, sessionId }));
|
||||
for (const conn of relay) {
|
||||
out.push({
|
||||
connectionId: conn.id,
|
||||
url: buildRelayWebSocketUrl({
|
||||
endpoint: conn.relayEndpoint,
|
||||
serverId: host.serverId,
|
||||
}),
|
||||
activeConnection: { type: "relay", endpoint: conn.relayEndpoint, display: "relay" },
|
||||
daemonPublicKeyB64: conn.daemonPublicKeyB64,
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
@@ -43,55 +68,50 @@ function buildCandidateUrls(daemon: HostProfile): string[] {
|
||||
|
||||
function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
|
||||
const { connectionStates } = useDaemonConnections();
|
||||
const { updateDaemon } = useDaemonRegistry();
|
||||
const { updateHost } = useDaemonRegistry();
|
||||
|
||||
const candidates = useMemo(() => buildCandidateUrls(daemon), [daemon]);
|
||||
const candidates = useMemo(() => buildCandidates(daemon), [daemon]);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const activeUrl = candidates[activeIndex] ?? candidates[0] ?? null;
|
||||
const active = candidates[activeIndex] ?? candidates[0] ?? null;
|
||||
const activeUrl = active?.url ?? null;
|
||||
|
||||
const lastAttemptedUrlRef = useRef<string | null>(null);
|
||||
const pendingMetadataWriteRef = useRef(false);
|
||||
const pendingPreferenceWriteRef = useRef(false);
|
||||
const upgradeProbeInFlightRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeUrl) {
|
||||
return;
|
||||
}
|
||||
// If the active URL fell out of the candidate set (e.g. endpoints updated), snap back.
|
||||
const idx = candidates.indexOf(activeUrl);
|
||||
const idx = candidates.findIndex((c) => c.url === activeUrl);
|
||||
if (idx === -1) {
|
||||
setActiveIndex(0);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [candidates.join("|")]);
|
||||
}, [candidates.map((c) => c.url).join("|")]);
|
||||
|
||||
if (!activeUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const connection = connectionStates.get(daemon.id);
|
||||
const connection = connectionStates.get(daemon.serverId);
|
||||
const status = connection?.status ?? "idle";
|
||||
const lastError = connection?.lastError ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!connection) return;
|
||||
|
||||
if (status === "online") {
|
||||
if (pendingMetadataWriteRef.current) return;
|
||||
const endpoint = (() => {
|
||||
try {
|
||||
return extractHostPortFromWebSocketUrl(activeUrl);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
if (!endpoint) return;
|
||||
if (daemon.metadata?.lastKnownGoodEndpoint === endpoint) return;
|
||||
if (status === "online") {
|
||||
if (!active) return;
|
||||
if (pendingPreferenceWriteRef.current) return;
|
||||
if (daemon.preferredConnectionId === active.connectionId) return;
|
||||
|
||||
pendingMetadataWriteRef.current = true;
|
||||
void updateDaemon(daemon.id, {
|
||||
metadata: { ...(daemon.metadata ?? {}), lastKnownGoodEndpoint: endpoint },
|
||||
pendingPreferenceWriteRef.current = true;
|
||||
void updateHost(daemon.serverId, {
|
||||
preferredConnectionId: active.connectionId,
|
||||
}).finally(() => {
|
||||
pendingMetadataWriteRef.current = false;
|
||||
pendingPreferenceWriteRef.current = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -106,23 +126,67 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
|
||||
}
|
||||
}
|
||||
}, [
|
||||
active,
|
||||
activeIndex,
|
||||
activeUrl,
|
||||
candidates.length,
|
||||
daemon.id,
|
||||
daemon.metadata,
|
||||
daemon.preferredConnectionId,
|
||||
daemon.serverId,
|
||||
lastError,
|
||||
status,
|
||||
updateDaemon,
|
||||
updateHost,
|
||||
connection,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connection) return;
|
||||
if (status !== "online") return;
|
||||
if (activeIndex === 0) return;
|
||||
|
||||
const best = candidates[0];
|
||||
if (!best) return;
|
||||
if (best.activeConnection.type !== "direct") return;
|
||||
|
||||
const intervalMs = 15_000;
|
||||
let cancelled = false;
|
||||
|
||||
const attemptUpgrade = async () => {
|
||||
if (cancelled) return;
|
||||
if (upgradeProbeInFlightRef.current) return;
|
||||
if (activeIndex === 0) return;
|
||||
|
||||
upgradeProbeInFlightRef.current = true;
|
||||
try {
|
||||
const { serverId } = await probeDaemonEndpoint(best.activeConnection.endpoint, {
|
||||
timeoutMs: 2000,
|
||||
});
|
||||
if (cancelled) return;
|
||||
if (serverId !== daemon.serverId) return;
|
||||
|
||||
lastAttemptedUrlRef.current = null;
|
||||
setActiveIndex(0);
|
||||
} catch {
|
||||
// ignore - we'll retry periodically
|
||||
} finally {
|
||||
upgradeProbeInFlightRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
void attemptUpgrade();
|
||||
const interval = setInterval(() => void attemptUpgrade(), intervalMs);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [activeIndex, candidates, connection, daemon.serverId, status]);
|
||||
|
||||
return (
|
||||
<SessionProvider
|
||||
key={`${daemon.id}:${activeUrl}`}
|
||||
key={`${daemon.serverId}:${activeUrl}`}
|
||||
serverUrl={activeUrl}
|
||||
serverId={daemon.id}
|
||||
daemonPublicKeyB64={daemon.daemonPublicKeyB64}
|
||||
serverId={daemon.serverId}
|
||||
activeConnection={active?.activeConnection ?? null}
|
||||
daemonPublicKeyB64={active?.daemonPublicKeyB64}
|
||||
>
|
||||
{null}
|
||||
</SessionProvider>
|
||||
@@ -138,7 +202,7 @@ export function MultiDaemonSessionHost() {
|
||||
return (
|
||||
<>
|
||||
{daemons.map((daemon) => (
|
||||
<ManagedDaemonSession key={daemon.id} daemon={daemon} />
|
||||
<ManagedDaemonSession key={daemon.serverId} daemon={daemon} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -123,6 +123,7 @@ export function PairLinkModal({ visible, onClose, onSaved }: PairLinkModalProps)
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Pairing link</Text>
|
||||
<InputComponent
|
||||
testID="pair-link-input"
|
||||
value={offerUrl}
|
||||
onChangeText={setOfferUrl}
|
||||
placeholder="https://app.paseo.sh/#offer=..."
|
||||
@@ -136,13 +137,14 @@ export function PairLinkModal({ visible, onClose, onSaved }: PairLinkModalProps)
|
||||
</View>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Pressable style={styles.button} onPress={handleClose} disabled={isSaving}>
|
||||
<Pressable style={styles.button} onPress={handleClose} disabled={isSaving} testID="pair-link-cancel">
|
||||
<Text style={styles.buttonText}>Cancel</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.button, styles.primaryButton, isSaving ? { opacity: 0.7 } : null]}
|
||||
onPress={() => void handleSave()}
|
||||
disabled={isSaving}
|
||||
testID="pair-link-submit"
|
||||
>
|
||||
<View style={styles.connectRow}>
|
||||
<Link size={16} color={theme.colors.palette.white} />
|
||||
|
||||
@@ -177,7 +177,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
}
|
||||
|
||||
if (voiceEligibleHosts.length === 1) {
|
||||
void startVoice(voiceEligibleHosts[0].daemon.id).catch((error) => {
|
||||
void startVoice(voiceEligibleHosts[0].daemon.serverId).catch((error) => {
|
||||
console.error("[SlidingSidebar] Failed to start voice", error);
|
||||
Alert.alert("Voice failed", "Unable to start Voice mode for this host.");
|
||||
});
|
||||
@@ -188,9 +188,9 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
}, [hasAnyConfiguredHosts, isVoiceMode, startVoice, stopVoice, voiceEligibleHosts]);
|
||||
|
||||
const handleSelectVoiceHost = useCallback(
|
||||
(daemonId: string) => {
|
||||
(serverId: string) => {
|
||||
setShowVoiceHostPicker(false);
|
||||
void startVoice(daemonId).catch((error) => {
|
||||
void startVoice(serverId).catch((error) => {
|
||||
console.error("[SlidingSidebar] Failed to start voice", error);
|
||||
Alert.alert("Voice failed", "Unable to start Voice mode for this host.");
|
||||
});
|
||||
@@ -320,14 +320,19 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
)}
|
||||
</Pressable>
|
||||
<View style={styles.footerIconRow}>
|
||||
<Pressable
|
||||
style={styles.footerIconButton}
|
||||
testID="sidebar-voice"
|
||||
onPress={handleToggleVoice}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<AudioLines
|
||||
size={20}
|
||||
<Pressable
|
||||
style={styles.footerIconButton}
|
||||
testID="sidebar-voice"
|
||||
nativeID="sidebar-voice"
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityLabel="Voice"
|
||||
accessibilityRole="button"
|
||||
onPress={handleToggleVoice}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<AudioLines
|
||||
size={20}
|
||||
color={
|
||||
isVoiceMode
|
||||
? theme.colors.foreground
|
||||
@@ -340,6 +345,12 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={styles.footerIconButton}
|
||||
testID="sidebar-settings"
|
||||
nativeID="sidebar-settings"
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityLabel="Settings"
|
||||
accessibilityRole="button"
|
||||
onPress={handleSettingsMobile}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
@@ -362,9 +373,9 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
<View style={styles.hostPickerList}>
|
||||
{voiceEligibleHosts.map((entry) => (
|
||||
<Pressable
|
||||
key={entry.daemon.id}
|
||||
key={entry.daemon.serverId}
|
||||
style={styles.hostPickerOption}
|
||||
onPress={() => handleSelectVoiceHost(entry.daemon.id)}
|
||||
onPress={() => handleSelectVoiceHost(entry.daemon.serverId)}
|
||||
>
|
||||
<Text style={styles.hostPickerOptionText}>{entry.daemon.label}</Text>
|
||||
</Pressable>
|
||||
@@ -436,6 +447,11 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
<Pressable
|
||||
style={styles.footerIconButton}
|
||||
testID="sidebar-voice"
|
||||
nativeID="sidebar-voice"
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityLabel="Voice"
|
||||
accessibilityRole="button"
|
||||
onPress={handleToggleVoice}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
@@ -453,6 +469,12 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={styles.footerIconButton}
|
||||
testID="sidebar-settings"
|
||||
nativeID="sidebar-settings"
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityLabel="Settings"
|
||||
accessibilityRole="button"
|
||||
onPress={handleSettingsDesktop}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
@@ -472,9 +494,9 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
<View style={styles.hostPickerList}>
|
||||
{voiceEligibleHosts.map((entry) => (
|
||||
<Pressable
|
||||
key={entry.daemon.id}
|
||||
key={entry.daemon.serverId}
|
||||
style={styles.hostPickerOption}
|
||||
onPress={() => handleSelectVoiceHost(entry.daemon.id)}
|
||||
onPress={() => handleSelectVoiceHost(entry.daemon.serverId)}
|
||||
>
|
||||
<Text style={styles.hostPickerOptionText}>{entry.daemon.label}</Text>
|
||||
</Pressable>
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { useState } from "react";
|
||||
import { Image, Pressable, Text, View, Platform } from "react-native";
|
||||
import { Image, Pressable, Text, View, Platform, ScrollView } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { QrCode, Link2 } from "lucide-react-native";
|
||||
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
|
||||
import type { HostProfile } from "@/contexts/daemon-registry-context";
|
||||
import { AddHostModal } from "./add-host-modal";
|
||||
import { PairLinkModal } from "./pair-link-modal";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
padding: theme.spacing[8],
|
||||
padding: theme.spacing[6],
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
@@ -70,9 +71,15 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const router = useRouter();
|
||||
const [isDirectOpen, setIsDirectOpen] = useState(false);
|
||||
const [isPasteLinkOpen, setIsPasteLinkOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<View style={styles.container} testID="welcome-screen">
|
||||
<ScrollView
|
||||
style={{ flex: 1, backgroundColor: theme.colors.surface0 }}
|
||||
contentContainerStyle={styles.container}
|
||||
showsVerticalScrollIndicator={false}
|
||||
testID="welcome-screen"
|
||||
>
|
||||
<Image
|
||||
source={require("../../assets/images/icon.png")}
|
||||
style={styles.logo}
|
||||
@@ -85,15 +92,26 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
<Pressable
|
||||
style={[styles.actionButton, styles.actionButtonPrimary]}
|
||||
onPress={() => setIsDirectOpen(true)}
|
||||
testID="welcome-direct-connection"
|
||||
>
|
||||
<Link2 size={18} color={theme.colors.palette.white} />
|
||||
<Text style={[styles.actionText, styles.actionTextPrimary]}>Direct connection</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={styles.actionButton}
|
||||
onPress={() => setIsPasteLinkOpen(true)}
|
||||
testID="welcome-paste-pairing-link"
|
||||
>
|
||||
<ClipboardPaste size={18} color={theme.colors.foreground} />
|
||||
<Text style={styles.actionText}>Paste pairing link</Text>
|
||||
</Pressable>
|
||||
|
||||
{Platform.OS !== "web" ? (
|
||||
<Pressable
|
||||
style={styles.actionButton}
|
||||
onPress={() => router.push("/pair-scan")}
|
||||
testID="welcome-scan-qr"
|
||||
>
|
||||
<QrCode size={18} color={theme.colors.foreground} />
|
||||
<Text style={styles.actionText}>Scan QR code</Text>
|
||||
@@ -106,9 +124,18 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
onClose={() => setIsDirectOpen(false)}
|
||||
onSaved={(profile) => {
|
||||
onHostAdded?.(profile);
|
||||
router.replace({ pathname: "/", params: { serverId: profile.id } });
|
||||
router.replace({ pathname: "/", params: { serverId: profile.serverId } });
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<PairLinkModal
|
||||
visible={isPasteLinkOpen}
|
||||
onClose={() => setIsPasteLinkOpen(false)}
|
||||
onSaved={(profile) => {
|
||||
onHostAdded?.(profile);
|
||||
router.replace({ pathname: "/", params: { serverId: profile.serverId } });
|
||||
}}
|
||||
/>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user