feat(mobile): Nostr identity — Amber NIP-55 deep link + nsec fallback
Some checks failed
CI / Typecheck & Lint (pull_request) Failing after 0s
Some checks failed
CI / Typecheck & Lint (pull_request) Failing after 0s
Implements mobile Nostr identity management per issue #29. Android — NIP-55 Amber integration: - Opens com.greenart7c3.nostrsigner via `nostrsigner:` URI scheme to retrieve the user's public key without exposing it to the app. - Listens for the `mobile://nostr-callback` deep link response and stores the resulting npub in Expo SecureStore. - Falls back to Play Store install prompt when Amber is not installed. iOS / manual fallback: - NostrConnectModal accepts an nsec1 paste-in, validates bech32, derives the pubkey via nostr-tools getPublicKey, and stores the key only in Expo SecureStore — never in AsyncStorage, Redux, or logs. Both platforms: - Truncated npub and signer type (Amber / nsec) shown in Settings. - "Disconnect Nostr" wipes all keys from SecureStore and resets state. - Identity persists across restarts via SecureStore. Supporting changes: - NostrContext: new React context for identity lifecycle. - NostrConnectModal: platform-aware bottom-sheet modal for connect flow. - TimmyContext: added apiBaseUrl/setApiBaseUrl/isConnected; URL persisted in AsyncStorage and restored on mount; circular dep broken via refs. - constants/colors: added field, textInverted, destructive, link colours. - constants/storage-keys: added SERVER_URL_KEY. - app.json: added Android intent filter for mobile://nostr-callback. - package.json: added nostr-tools and expo-secure-store dependencies. Fixes #29 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
274
artifacts/mobile/context/NostrContext.tsx
Normal file
274
artifacts/mobile/context/NostrContext.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* NostrContext — Nostr identity management for mobile.
|
||||
*
|
||||
* Android: NIP-55 Amber deep-link signing (com.greenart7c3.nostrsigner).
|
||||
* Opens Amber via the `nostrsigner:` URI scheme to retrieve the user's
|
||||
* public key; falls back to the Play Store install prompt when Amber is
|
||||
* not installed.
|
||||
*
|
||||
* iOS / manual fallback: nsec paste-in stored exclusively in Expo SecureStore.
|
||||
* The raw key is NEVER written to AsyncStorage, Redux state, or logs.
|
||||
*/
|
||||
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Linking, Platform } from "react-native";
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import { getPublicKey, nip19 } from "nostr-tools";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type NostrSignerType = "amber" | "nsec" | null;
|
||||
|
||||
export type NostrConnectResult =
|
||||
| { success: true }
|
||||
| { success: false; error: string };
|
||||
|
||||
type NostrContextValue = {
|
||||
/** bech32 public key (npub1…), null when no identity is loaded */
|
||||
npub: string | null;
|
||||
/** Raw hex public key, null when no identity is loaded */
|
||||
pubkeyHex: string | null;
|
||||
/** How the key was connected */
|
||||
signerType: NostrSignerType;
|
||||
/** True when an identity is loaded */
|
||||
nostrConnected: boolean;
|
||||
/** True only on Android — Amber integration available */
|
||||
canUseAmber: boolean;
|
||||
/** Android only: launch Amber to retrieve the user's public key */
|
||||
connectWithAmber: () => Promise<void>;
|
||||
/** Both platforms: validate & store an nsec; derive and cache the npub */
|
||||
connectWithNsec: (nsec: string) => Promise<NostrConnectResult>;
|
||||
/** Wipe all Nostr credentials from SecureStore and reset state */
|
||||
disconnect: () => Promise<void>;
|
||||
};
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const SECURE_KEY_NSEC = "nostr.nsec";
|
||||
const SECURE_KEY_NPUB = "nostr.npub";
|
||||
const SECURE_KEY_SIGNER_TYPE = "nostr.signer_type";
|
||||
|
||||
/** The deep-link scheme declared in app.json */
|
||||
const APP_SCHEME = "mobile";
|
||||
/** Path Amber will call back to with the pubkey result */
|
||||
const AMBER_CALLBACK_URL = `${APP_SCHEME}://nostr-callback`;
|
||||
const AMBER_PACKAGE = "com.greenart7c3.nostrsigner";
|
||||
const AMBER_PLAY_STORE_URL =
|
||||
"https://play.google.com/store/apps/details?id=com.greenart7c3.nostrsigner";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Truncate an npub for display: "npub1abcde…xyz12" */
|
||||
export function truncateNpub(npub: string): string {
|
||||
if (npub.length <= 20) return npub;
|
||||
return `${npub.substring(0, 10)}…${npub.substring(npub.length - 5)}`;
|
||||
}
|
||||
|
||||
// ─── Context ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const NostrContext = createContext<NostrContextValue | null>(null);
|
||||
|
||||
export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
const [npub, setNpub] = useState<string | null>(null);
|
||||
const [pubkeyHex, setPubkeyHex] = useState<string | null>(null);
|
||||
const [signerType, setSignerType] = useState<NostrSignerType>(null);
|
||||
|
||||
const canUseAmber = Platform.OS === "android";
|
||||
|
||||
// ── Load persisted identity on mount ──────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
async function loadIdentity() {
|
||||
try {
|
||||
const [storedNpub, storedSignerType] = await Promise.all([
|
||||
SecureStore.getItemAsync(SECURE_KEY_NPUB),
|
||||
SecureStore.getItemAsync(SECURE_KEY_SIGNER_TYPE),
|
||||
]);
|
||||
|
||||
if (storedNpub && storedSignerType) {
|
||||
setNpub(storedNpub);
|
||||
setSignerType(storedSignerType as NostrSignerType);
|
||||
try {
|
||||
const decoded = nip19.decode(storedNpub);
|
||||
if (decoded.type === "npub") {
|
||||
setPubkeyHex(decoded.data as string);
|
||||
}
|
||||
} catch {
|
||||
// npub decode failure — identity still "connected", pubkeyHex stays null
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// SecureStore unavailable (e.g. web build) — proceed without identity
|
||||
}
|
||||
}
|
||||
loadIdentity();
|
||||
}, []);
|
||||
|
||||
// ── Handle Amber callback deep link (Android) ─────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
if (!canUseAmber) return;
|
||||
|
||||
function handleUrl({ url }: { url: string }) {
|
||||
if (!url.startsWith(`${APP_SCHEME}://nostr-callback`)) return;
|
||||
|
||||
try {
|
||||
// React Native's URL parsing is not available in all environments;
|
||||
// parse manually to avoid importing a polyfill.
|
||||
const queryStart = url.indexOf("?");
|
||||
if (queryStart === -1) return;
|
||||
const params = new URLSearchParams(url.slice(queryStart + 1));
|
||||
const result = params.get("result");
|
||||
if (!result) return;
|
||||
|
||||
// Amber returns the hex pubkey in `result`
|
||||
let hexKey = result;
|
||||
if (result.startsWith("npub1")) {
|
||||
const decoded = nip19.decode(result);
|
||||
if (decoded.type === "npub") hexKey = decoded.data as string;
|
||||
}
|
||||
|
||||
const derivedNpub = nip19.npubEncode(hexKey);
|
||||
|
||||
// Persist — no private key stored for Amber flow
|
||||
SecureStore.setItemAsync(SECURE_KEY_NPUB, derivedNpub).catch(() => {});
|
||||
SecureStore.setItemAsync(SECURE_KEY_SIGNER_TYPE, "amber").catch(() => {});
|
||||
|
||||
setNpub(derivedNpub);
|
||||
setPubkeyHex(hexKey);
|
||||
setSignerType("amber");
|
||||
} catch {
|
||||
// Malformed callback — silently ignore
|
||||
}
|
||||
}
|
||||
|
||||
const subscription = Linking.addEventListener("url", handleUrl);
|
||||
return () => subscription.remove();
|
||||
}, [canUseAmber]);
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────────────────
|
||||
|
||||
const connectWithAmber = useCallback(async () => {
|
||||
// NIP-55: request the user's public key from Amber
|
||||
const amberUri = `nostrsigner:?type=get_public_key&compressionType=none&returnType=signature&callbackUrl=${encodeURIComponent(AMBER_CALLBACK_URL)}`;
|
||||
|
||||
let canOpen = false;
|
||||
try {
|
||||
canOpen = await Linking.canOpenURL(`nostrsigner:`);
|
||||
} catch {
|
||||
canOpen = false;
|
||||
}
|
||||
|
||||
if (canOpen) {
|
||||
await Linking.openURL(amberUri);
|
||||
} else {
|
||||
// Amber not installed — direct user to Play Store
|
||||
await Linking.openURL(AMBER_PLAY_STORE_URL);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const connectWithNsec = useCallback(
|
||||
async (nsec: string): Promise<NostrConnectResult> => {
|
||||
const trimmed = nsec.trim();
|
||||
|
||||
if (!trimmed.startsWith("nsec1")) {
|
||||
return { success: false, error: "Key must start with nsec1" };
|
||||
}
|
||||
|
||||
let decoded: ReturnType<typeof nip19.decode>;
|
||||
try {
|
||||
decoded = nip19.decode(trimmed);
|
||||
} catch {
|
||||
return { success: false, error: "Invalid bech32 encoding" };
|
||||
}
|
||||
|
||||
if (decoded.type !== "nsec") {
|
||||
return { success: false, error: "Not a valid nsec key" };
|
||||
}
|
||||
|
||||
let hexPubkey: string;
|
||||
try {
|
||||
const sk = decoded.data as Uint8Array;
|
||||
hexPubkey = getPublicKey(sk);
|
||||
} catch {
|
||||
return { success: false, error: "Could not derive public key" };
|
||||
}
|
||||
|
||||
const derivedNpub = nip19.npubEncode(hexPubkey);
|
||||
|
||||
try {
|
||||
// Store only in SecureStore — never AsyncStorage, never logs
|
||||
await SecureStore.setItemAsync(SECURE_KEY_NSEC, trimmed);
|
||||
await SecureStore.setItemAsync(SECURE_KEY_NPUB, derivedNpub);
|
||||
await SecureStore.setItemAsync(SECURE_KEY_SIGNER_TYPE, "nsec");
|
||||
} catch {
|
||||
return { success: false, error: "Failed to store key securely" };
|
||||
}
|
||||
|
||||
setNpub(derivedNpub);
|
||||
setPubkeyHex(hexPubkey);
|
||||
setSignerType("nsec");
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const disconnect = useCallback(async () => {
|
||||
try {
|
||||
await Promise.all([
|
||||
SecureStore.deleteItemAsync(SECURE_KEY_NSEC),
|
||||
SecureStore.deleteItemAsync(SECURE_KEY_NPUB),
|
||||
SecureStore.deleteItemAsync(SECURE_KEY_SIGNER_TYPE),
|
||||
]);
|
||||
} catch {
|
||||
// Best-effort cleanup; reset state regardless
|
||||
}
|
||||
setNpub(null);
|
||||
setPubkeyHex(null);
|
||||
setSignerType(null);
|
||||
}, []);
|
||||
|
||||
// ── Context value ─────────────────────────────────────────────────────────
|
||||
|
||||
const value = useMemo<NostrContextValue>(
|
||||
() => ({
|
||||
npub,
|
||||
pubkeyHex,
|
||||
signerType,
|
||||
nostrConnected: npub !== null,
|
||||
canUseAmber,
|
||||
connectWithAmber,
|
||||
connectWithNsec,
|
||||
disconnect,
|
||||
}),
|
||||
[
|
||||
npub,
|
||||
pubkeyHex,
|
||||
signerType,
|
||||
canUseAmber,
|
||||
connectWithAmber,
|
||||
connectWithNsec,
|
||||
disconnect,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<NostrContext.Provider value={value}>{children}</NostrContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useNostr(): NostrContextValue {
|
||||
const ctx = useContext(NostrContext);
|
||||
if (!ctx) throw new Error("useNostr must be used within NostrProvider");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export { AMBER_PACKAGE };
|
||||
@@ -8,6 +8,9 @@ import React, {
|
||||
useState,
|
||||
} from "react";
|
||||
import { AppState, Platform } from "react-native";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
|
||||
import { SERVER_URL_KEY } from "@/constants/storage-keys";
|
||||
|
||||
export type TimmyMood = "idle" | "thinking" | "working" | "speaking";
|
||||
|
||||
@@ -22,33 +25,42 @@ export type WsEvent = {
|
||||
count?: number;
|
||||
};
|
||||
|
||||
export type ConnectionStatus = "connecting" | "connected" | "disconnected" | "reconnecting" | "error";
|
||||
export type ConnectionStatus =
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "disconnected"
|
||||
| "reconnecting"
|
||||
| "error";
|
||||
|
||||
type TimmyContextValue = {
|
||||
timmyMood: TimmyMood;
|
||||
connectionStatus: ConnectionStatus;
|
||||
/** True when the WebSocket is fully open */
|
||||
isConnected: boolean;
|
||||
recentEvents: WsEvent[];
|
||||
send: (msg: object) => void;
|
||||
sendVisitorMessage: (text: string) => void;
|
||||
visitorId: string;
|
||||
/** Current API / WebSocket base domain */
|
||||
apiBaseUrl: string;
|
||||
/** Persist a new base URL and reconnect the WebSocket */
|
||||
setApiBaseUrl: (url: string) => void;
|
||||
};
|
||||
|
||||
const TimmyContext = createContext<TimmyContextValue | null>(null);
|
||||
|
||||
const MAX_EVENTS = 100;
|
||||
const BASE_URL = process.env.EXPO_PUBLIC_DOMAIN ?? "";
|
||||
const ENV_DOMAIN = process.env["EXPO_PUBLIC_DOMAIN"] ?? "";
|
||||
const VISITOR_ID =
|
||||
Date.now().toString() + Math.random().toString(36).substr(2, 9);
|
||||
|
||||
function getWsUrl(): string {
|
||||
let domain = BASE_URL;
|
||||
if (!domain) {
|
||||
domain = "localhost:8080";
|
||||
}
|
||||
domain = domain.replace(/^https?:\/\//, "");
|
||||
domain = domain.replace(/\/$/, "");
|
||||
const proto = domain.startsWith("localhost") ? "ws" : "wss";
|
||||
return `${proto}://${domain}/api/ws`;
|
||||
function buildWsUrl(domain: string): string {
|
||||
let d = domain.trim();
|
||||
if (!d) d = "localhost:8080";
|
||||
d = d.replace(/^https?:\/\//, "");
|
||||
d = d.replace(/\/$/, "");
|
||||
const proto = d.startsWith("localhost") ? "ws" : "wss";
|
||||
return `${proto}://${d}/api/ws`;
|
||||
}
|
||||
|
||||
function deriveMood(agentStates: Record<string, string>): TimmyMood {
|
||||
@@ -63,10 +75,12 @@ function deriveMood(agentStates: Record<string, string>): TimmyMood {
|
||||
}
|
||||
|
||||
export function TimmyProvider({ children }: { children: React.ReactNode }) {
|
||||
const [apiBaseUrl, setApiBaseUrlState] = useState(ENV_DOMAIN);
|
||||
const [timmyMood, setTimmyMood] = useState<TimmyMood>("idle");
|
||||
const [connectionStatus, setConnectionStatus] =
|
||||
useState<ConnectionStatus>("connecting");
|
||||
const [recentEvents, setRecentEvents] = useState<WsEvent[]>([]);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const retryCountRef = useRef(0);
|
||||
@@ -77,6 +91,32 @@ export function TimmyProvider({ children }: { children: React.ReactNode }) {
|
||||
delta: "idle",
|
||||
});
|
||||
const speakingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Stable ref so WebSocket callbacks always read the current URL
|
||||
const apiBaseUrlRef = useRef(apiBaseUrl);
|
||||
// Stable refs to break the connectWs ↔ scheduleRetry circular dependency
|
||||
const connectWsRef = useRef<() => void>(() => {});
|
||||
const scheduleRetryRef = useRef<() => void>(() => {});
|
||||
|
||||
// ── Load persisted URL on mount ────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
AsyncStorage.getItem(SERVER_URL_KEY)
|
||||
.then((stored) => {
|
||||
if (stored) {
|
||||
setApiBaseUrlState(stored);
|
||||
apiBaseUrlRef.current = stored;
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const setApiBaseUrl = useCallback((url: string) => {
|
||||
setApiBaseUrlState(url);
|
||||
apiBaseUrlRef.current = url;
|
||||
AsyncStorage.setItem(SERVER_URL_KEY, url).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// ── WebSocket helpers ──────────────────────────────────────────────────
|
||||
|
||||
const addEvent = useCallback((evt: Omit<WsEvent, "id" | "timestamp">) => {
|
||||
const entry: WsEvent = {
|
||||
@@ -94,14 +134,14 @@ export function TimmyProvider({ children }: { children: React.ReactNode }) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
const url = getWsUrl();
|
||||
const url = buildWsUrl(apiBaseUrlRef.current);
|
||||
setConnectionStatus("connecting");
|
||||
let ws: WebSocket;
|
||||
try {
|
||||
ws = new WebSocket(url);
|
||||
} catch {
|
||||
setConnectionStatus("error");
|
||||
scheduleRetry();
|
||||
scheduleRetryRef.current();
|
||||
return;
|
||||
}
|
||||
wsRef.current = ws;
|
||||
@@ -134,10 +174,7 @@ export function TimmyProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
if (type === "world_state") {
|
||||
const states = (msg.agentStates as Record<string, string>) ?? {};
|
||||
agentStatesRef.current = {
|
||||
...agentStatesRef.current,
|
||||
...states,
|
||||
};
|
||||
agentStatesRef.current = { ...agentStatesRef.current, ...states };
|
||||
setTimmyMood(deriveMood(agentStatesRef.current));
|
||||
return;
|
||||
}
|
||||
@@ -187,7 +224,7 @@ export function TimmyProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
ws.onclose = () => {
|
||||
setConnectionStatus("disconnected");
|
||||
scheduleRetry();
|
||||
scheduleRetryRef.current();
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
@@ -200,9 +237,15 @@ export function TimmyProvider({ children }: { children: React.ReactNode }) {
|
||||
const delay = Math.min(1000 * Math.pow(2, retryCountRef.current), 30000);
|
||||
retryCountRef.current += 1;
|
||||
retryTimerRef.current = setTimeout(() => {
|
||||
connectWs();
|
||||
connectWsRef.current();
|
||||
}, delay);
|
||||
}, [connectWs]);
|
||||
}, []);
|
||||
|
||||
// Keep the stable refs current after every render
|
||||
connectWsRef.current = connectWs;
|
||||
scheduleRetryRef.current = scheduleRetry;
|
||||
|
||||
// ── Initial connect ────────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
connectWs();
|
||||
@@ -216,7 +259,19 @@ export function TimmyProvider({ children }: { children: React.ReactNode }) {
|
||||
};
|
||||
}, [connectWs]);
|
||||
|
||||
// AppState-aware WebSocket reconnect on foreground
|
||||
// Reconnect when apiBaseUrl changes (skip the very first render)
|
||||
const isFirstRenderRef = useRef(true);
|
||||
useEffect(() => {
|
||||
if (isFirstRenderRef.current) {
|
||||
isFirstRenderRef.current = false;
|
||||
return;
|
||||
}
|
||||
retryCountRef.current = 0;
|
||||
connectWs();
|
||||
}, [apiBaseUrl, connectWs]);
|
||||
|
||||
// ── AppState-aware reconnect on foreground ─────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "web") return;
|
||||
|
||||
@@ -229,20 +284,17 @@ export function TimmyProvider({ children }: { children: React.ReactNode }) {
|
||||
const isNowActive = nextAppState === "active";
|
||||
|
||||
if (wasBackground && isNowActive) {
|
||||
// App returned to foreground — check if WS is still alive
|
||||
const ws = wsRef.current;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
// Cancel any pending retry so we don't create duplicates
|
||||
if (retryTimerRef.current) {
|
||||
clearTimeout(retryTimerRef.current);
|
||||
retryTimerRef.current = null;
|
||||
}
|
||||
retryCountRef.current = 0;
|
||||
setConnectionStatus("reconnecting");
|
||||
connectWs();
|
||||
connectWsRef.current();
|
||||
}
|
||||
} else if (nextAppState === "background") {
|
||||
// Proactively close the WS to avoid OS killing it mid-frame
|
||||
if (retryTimerRef.current) {
|
||||
clearTimeout(retryTimerRef.current);
|
||||
retryTimerRef.current = null;
|
||||
@@ -262,7 +314,9 @@ export function TimmyProvider({ children }: { children: React.ReactNode }) {
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
}, [connectWs]);
|
||||
}, []);
|
||||
|
||||
// ── Outbound messages ──────────────────────────────────────────────────
|
||||
|
||||
const send = useCallback((msg: object) => {
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
@@ -270,28 +324,42 @@ export function TimmyProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const sendVisitorMessage = useCallback(
|
||||
(text: string) => {
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(
|
||||
JSON.stringify({ type: "visitor_message", visitorId: VISITOR_ID, text })
|
||||
);
|
||||
setTimmyMood("thinking");
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
const sendVisitorMessage = useCallback((text: string) => {
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(
|
||||
JSON.stringify({
|
||||
type: "visitor_message",
|
||||
visitorId: VISITOR_ID,
|
||||
text,
|
||||
})
|
||||
);
|
||||
setTimmyMood("thinking");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
// ── Context value ──────────────────────────────────────────────────────
|
||||
|
||||
const value = useMemo<TimmyContextValue>(
|
||||
() => ({
|
||||
timmyMood,
|
||||
connectionStatus,
|
||||
isConnected: connectionStatus === "connected",
|
||||
recentEvents,
|
||||
send,
|
||||
sendVisitorMessage,
|
||||
visitorId: VISITOR_ID,
|
||||
apiBaseUrl,
|
||||
setApiBaseUrl,
|
||||
}),
|
||||
[
|
||||
timmyMood,
|
||||
connectionStatus,
|
||||
recentEvents,
|
||||
send,
|
||||
sendVisitorMessage,
|
||||
visitorId: VISITOR_ID,
|
||||
}),
|
||||
[timmyMood, connectionStatus, recentEvents, send, sendVisitorMessage]
|
||||
apiBaseUrl,
|
||||
setApiBaseUrl,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user