@me2em/react 0.1.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +195 -0
- package/dist/index.d.ts +367 -0
- package/dist/index.js +1225 -0
- package/dist/index.js.map +1 -0
- package/package.json +51 -0
- package/src/headless/context-crypto.ts +91 -0
- package/src/headless/context-key-store.ts +124 -0
- package/src/headless/display-flow.ts +113 -0
- package/src/headless/identity-context-storage.ts +205 -0
- package/src/headless/identity-context-types.ts +30 -0
- package/src/headless/identity-flow.ts +106 -0
- package/src/headless/import-flow.ts +82 -0
- package/src/headless/index.ts +53 -0
- package/src/headless/seed-words.ts +75 -0
- package/src/headless/types.ts +21 -0
- package/src/headless/verify-flow.ts +112 -0
- package/src/index.ts +2 -0
- package/src/react/Me2emProvider.tsx +33 -0
- package/src/react/PassphraseInput.tsx +37 -0
- package/src/react/SeedPhraseDisplay.tsx +92 -0
- package/src/react/SeedPhraseImport.tsx +110 -0
- package/src/react/SeedPhraseVerify.tsx +128 -0
- package/src/react/context.ts +19 -0
- package/src/react/index.ts +23 -0
- package/src/react/useCreateIdentity.ts +77 -0
- package/src/react/useHandle.ts +27 -0
- package/src/react/useIdentityContext.ts +88 -0
- package/src/react/useImportSeed.ts +60 -0
- package/src/react/useSession.ts +157 -0
- package/src/react/useSubHandle.ts +30 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useState,
|
|
3
|
+
useCallback,
|
|
4
|
+
useMemo,
|
|
5
|
+
type ReactNode,
|
|
6
|
+
} from 'react';
|
|
7
|
+
import { Identity } from '@me2em/core';
|
|
8
|
+
import { Me2emContext, type Me2emContextValue } from './context.js';
|
|
9
|
+
|
|
10
|
+
export function Me2emProvider({ children }: { children: ReactNode }) {
|
|
11
|
+
const [identity, setIdentity] = useState<Identity | null>(null);
|
|
12
|
+
|
|
13
|
+
const activateIdentity = useCallback(async (seed: Uint8Array): Promise<boolean> => {
|
|
14
|
+
try {
|
|
15
|
+
const id = await Identity.fromSeed(seed);
|
|
16
|
+
setIdentity(id);
|
|
17
|
+
return true;
|
|
18
|
+
} catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}, []);
|
|
22
|
+
|
|
23
|
+
const clearIdentity = useCallback(() => {
|
|
24
|
+
setIdentity(null);
|
|
25
|
+
}, []);
|
|
26
|
+
|
|
27
|
+
const value = useMemo<Me2emContextValue>(
|
|
28
|
+
() => ({ identity, hasIdentity: identity !== null, activateIdentity, clearIdentity }),
|
|
29
|
+
[identity, activateIdentity, clearIdentity]
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
return <Me2emContext.Provider value={value}>{children}</Me2emContext.Provider>;
|
|
33
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// packages/react/src/react/PassphraseInput.tsx
|
|
2
|
+
import { useState, useCallback } from 'react';
|
|
3
|
+
|
|
4
|
+
export interface PassphraseInputProps {
|
|
5
|
+
onPassphraseChange: (p: string) => void;
|
|
6
|
+
showWarnings?: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function PassphraseInput({
|
|
10
|
+
onPassphraseChange,
|
|
11
|
+
showWarnings = true,
|
|
12
|
+
}: PassphraseInputProps) {
|
|
13
|
+
const handleChange = useCallback(
|
|
14
|
+
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
15
|
+
const value = e.target.value.normalize('NFKC');
|
|
16
|
+
onPassphraseChange(value);
|
|
17
|
+
},
|
|
18
|
+
[onPassphraseChange]
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<div>
|
|
23
|
+
<input
|
|
24
|
+
type="password"
|
|
25
|
+
data-testid="passphrase"
|
|
26
|
+
aria-label="BIP39 passphrase"
|
|
27
|
+
onChange={handleChange}
|
|
28
|
+
/>
|
|
29
|
+
{showWarnings && (
|
|
30
|
+
<div data-testid="warning">
|
|
31
|
+
Passphrase is case-sensitive and NOT recoverable. A different
|
|
32
|
+
passphrase silently derives a different wallet.
|
|
33
|
+
</div>
|
|
34
|
+
)}
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// packages/react/src/react/SeedPhraseDisplay.tsx
|
|
2
|
+
import { useReducer, useState } from 'react';
|
|
3
|
+
import {
|
|
4
|
+
displayFlowReducer,
|
|
5
|
+
initialDisplayState,
|
|
6
|
+
} from '../headless/display-flow.js';
|
|
7
|
+
|
|
8
|
+
export interface SeedPhraseDisplayProps {
|
|
9
|
+
words: string[];
|
|
10
|
+
onDismiss?: () => void;
|
|
11
|
+
onCopyAllowed?: () => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function SeedPhraseDisplay({
|
|
15
|
+
words,
|
|
16
|
+
onDismiss,
|
|
17
|
+
onCopyAllowed,
|
|
18
|
+
}: SeedPhraseDisplayProps) {
|
|
19
|
+
const [state, dispatch] = useReducer(displayFlowReducer, initialDisplayState);
|
|
20
|
+
const [copyFailed, setCopyFailed] = useState(false);
|
|
21
|
+
|
|
22
|
+
const { status, copyCount } = state;
|
|
23
|
+
|
|
24
|
+
const handleReveal = () => {
|
|
25
|
+
dispatch({ type: 'REVEAL', at: Date.now() });
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const handleCopy = async () => {
|
|
29
|
+
if (status === 'revealed' && copyCount === 0) {
|
|
30
|
+
try {
|
|
31
|
+
await (navigator as any).clipboard.writeText(words.join(' '));
|
|
32
|
+
dispatch({ type: 'COPY', at: Date.now() });
|
|
33
|
+
setCopyFailed(false);
|
|
34
|
+
onCopyAllowed?.();
|
|
35
|
+
} catch {
|
|
36
|
+
setCopyFailed(true);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const handleDismiss = () => {
|
|
42
|
+
dispatch({ type: 'DISMISS' });
|
|
43
|
+
onDismiss?.();
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
if (status === 'hidden') {
|
|
47
|
+
return (
|
|
48
|
+
<button data-testid="reveal" onClick={handleReveal}>
|
|
49
|
+
Reveal
|
|
50
|
+
</button>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (status === 'dismissed') {
|
|
55
|
+
return (
|
|
56
|
+
<div data-testid="dismissed">
|
|
57
|
+
Phrase dismissed
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div>
|
|
64
|
+
<ol data-testid="words">
|
|
65
|
+
{words.map((word, i) => (
|
|
66
|
+
<li
|
|
67
|
+
key={i}
|
|
68
|
+
data-testid={`word-${i}`}
|
|
69
|
+
data-position={i}
|
|
70
|
+
>
|
|
71
|
+
{word}
|
|
72
|
+
</li>
|
|
73
|
+
))}
|
|
74
|
+
</ol>
|
|
75
|
+
{copyFailed && (
|
|
76
|
+
<div data-testid="copy-error">
|
|
77
|
+
Copy failed — try again
|
|
78
|
+
</div>
|
|
79
|
+
)}
|
|
80
|
+
<button
|
|
81
|
+
data-testid="copy-all"
|
|
82
|
+
onClick={handleCopy}
|
|
83
|
+
disabled={copyCount === 1}
|
|
84
|
+
>
|
|
85
|
+
{copyCount === 1 ? 'Copied' : 'Copy all'}
|
|
86
|
+
</button>
|
|
87
|
+
<button data-testid="dismiss" onClick={handleDismiss}>
|
|
88
|
+
Hide
|
|
89
|
+
</button>
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// packages/react/src/react/SeedPhraseImport.tsx
|
|
2
|
+
import { useReducer, useCallback, useEffect, useRef } from 'react';
|
|
3
|
+
import {
|
|
4
|
+
importFlowReducer,
|
|
5
|
+
initImportState,
|
|
6
|
+
type ImportState,
|
|
7
|
+
} from '../headless/import-flow.js';
|
|
8
|
+
import { wordlist } from '@me2em/core';
|
|
9
|
+
|
|
10
|
+
export interface SeedPhraseImportProps {
|
|
11
|
+
expectedCount?: 12 | 24;
|
|
12
|
+
wordlist?: readonly string[];
|
|
13
|
+
onImported: (words: string[]) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function SeedPhraseImport({
|
|
17
|
+
expectedCount: propCount,
|
|
18
|
+
wordlist: propWordlist,
|
|
19
|
+
onImported,
|
|
20
|
+
}: SeedPhraseImportProps) {
|
|
21
|
+
const allWords = propWordlist ?? wordlist;
|
|
22
|
+
|
|
23
|
+
const [state, dispatch] = useReducer(
|
|
24
|
+
importFlowReducer,
|
|
25
|
+
{ expectedCount: propCount ?? 12 } as const,
|
|
26
|
+
(params) => initImportState(params.expectedCount)
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const count = state.expectedCount;
|
|
30
|
+
|
|
31
|
+
const prevStatusRef = useRef<ImportState['status']>(state.status);
|
|
32
|
+
|
|
33
|
+
const lastImportedRef = useRef<string | null>(null);
|
|
34
|
+
|
|
35
|
+
// Auto-check on every word change
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
dispatch({ type: 'CHECK' });
|
|
38
|
+
}, [state.words]);
|
|
39
|
+
|
|
40
|
+
// Call onImported once when transitioning to valid
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
const prevStatus = prevStatusRef.current;
|
|
43
|
+
prevStatusRef.current = state.status;
|
|
44
|
+
|
|
45
|
+
if (state.status === 'valid' && prevStatus !== 'valid') {
|
|
46
|
+
const key = (state.words as string[]).join('\0');
|
|
47
|
+
if (lastImportedRef.current !== key) {
|
|
48
|
+
lastImportedRef.current = key;
|
|
49
|
+
onImported(state.words as string[]);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}, [state.status, state.words, onImported]);
|
|
53
|
+
|
|
54
|
+
const handleCountChange = (n: 12 | 24) => {
|
|
55
|
+
dispatch({ type: 'INIT', expectedCount: n });
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const handleWordChange = (position: number, value: string) => {
|
|
59
|
+
dispatch({ type: 'SET_WORD', position, word: value });
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const isWordInvalid = (position: number): boolean => {
|
|
63
|
+
if (state.status !== 'invalid' && state.status !== 'valid') return false;
|
|
64
|
+
const word = state.words[position];
|
|
65
|
+
if (word === null) return false;
|
|
66
|
+
return !allWords.includes(word);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const isImportEnabled = state.status === 'valid';
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div>
|
|
73
|
+
<div>
|
|
74
|
+
<button
|
|
75
|
+
data-testid="count-12"
|
|
76
|
+
onClick={() => handleCountChange(12)}
|
|
77
|
+
disabled={count === 12}
|
|
78
|
+
>
|
|
79
|
+
12 words
|
|
80
|
+
</button>
|
|
81
|
+
<button
|
|
82
|
+
data-testid="count-24"
|
|
83
|
+
onClick={() => handleCountChange(24)}
|
|
84
|
+
disabled={count === 24}
|
|
85
|
+
>
|
|
86
|
+
24 words
|
|
87
|
+
</button>
|
|
88
|
+
</div>
|
|
89
|
+
<div data-testid="inputs">
|
|
90
|
+
{Array.from({ length: count }, (_, i) => (
|
|
91
|
+
<input
|
|
92
|
+
key={i}
|
|
93
|
+
data-testid={`word-${i}`}
|
|
94
|
+
aria-label={`Word ${i + 1}`}
|
|
95
|
+
value={state.words[i] ?? ''}
|
|
96
|
+
onChange={(e) => handleWordChange(i, e.target.value)}
|
|
97
|
+
data-invalid={isWordInvalid(i) ? 'true' : undefined}
|
|
98
|
+
/>
|
|
99
|
+
))}
|
|
100
|
+
</div>
|
|
101
|
+
<button
|
|
102
|
+
data-testid="import"
|
|
103
|
+
disabled={!isImportEnabled}
|
|
104
|
+
onClick={() => dispatch({ type: 'CHECK' })}
|
|
105
|
+
>
|
|
106
|
+
Import
|
|
107
|
+
</button>
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// packages/react/src/react/SeedPhraseVerify.tsx
|
|
2
|
+
import { useReducer, useCallback, useState, useEffect, useRef } from 'react';
|
|
3
|
+
import {
|
|
4
|
+
verifyFlowReducer,
|
|
5
|
+
initVerifyState,
|
|
6
|
+
type VerifyState,
|
|
7
|
+
} from '../headless/verify-flow.js';
|
|
8
|
+
import { wordlist } from '@me2em/core';
|
|
9
|
+
|
|
10
|
+
export interface SeedPhraseVerifyProps {
|
|
11
|
+
realWords: string[];
|
|
12
|
+
gridSize?: number;
|
|
13
|
+
wordlist?: readonly string[];
|
|
14
|
+
onVerified: () => void;
|
|
15
|
+
onFailed?: (attempts: number) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const DEFAULT_GRID_SIZE_OFFSET = 8;
|
|
19
|
+
|
|
20
|
+
export function SeedPhraseVerify({
|
|
21
|
+
realWords,
|
|
22
|
+
gridSize,
|
|
23
|
+
wordlist: propWordlist,
|
|
24
|
+
onVerified,
|
|
25
|
+
onFailed,
|
|
26
|
+
}: SeedPhraseVerifyProps) {
|
|
27
|
+
const grid = gridSize ?? realWords.length + DEFAULT_GRID_SIZE_OFFSET;
|
|
28
|
+
const allWords = propWordlist ?? wordlist;
|
|
29
|
+
const baseSeed = 42;
|
|
30
|
+
|
|
31
|
+
const [state, dispatch] = useReducer(
|
|
32
|
+
verifyFlowReducer,
|
|
33
|
+
{
|
|
34
|
+
realWords,
|
|
35
|
+
gridSize: grid,
|
|
36
|
+
randomSeed: baseSeed,
|
|
37
|
+
allWords,
|
|
38
|
+
} as const,
|
|
39
|
+
(params) => initVerifyState(params.realWords, params.gridSize, params.randomSeed, params.allWords)
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const [retryCount, setRetryCount] = useState(0);
|
|
43
|
+
|
|
44
|
+
const { status, grid: gridData, selected } = state;
|
|
45
|
+
|
|
46
|
+
// Guard against re-entrant callbacks after status change
|
|
47
|
+
const notifiedRef = useRef(false);
|
|
48
|
+
|
|
49
|
+
const handleSelect = useCallback(
|
|
50
|
+
(index: number) => {
|
|
51
|
+
if (status === 'correct' || status === 'incorrect') return;
|
|
52
|
+
if (selected.length >= gridData.correctIndices.length) return;
|
|
53
|
+
dispatch({ type: 'SELECT', index });
|
|
54
|
+
},
|
|
55
|
+
[status, selected.length, gridData.correctIndices.length]
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
// Single effect: auto-check when enough words selected, then notify result
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (notifiedRef.current) return;
|
|
61
|
+
|
|
62
|
+
// Auto-check when enough words selected
|
|
63
|
+
if (
|
|
64
|
+
selected.length === gridData.correctIndices.length &&
|
|
65
|
+
selected.length > 0 &&
|
|
66
|
+
status === 'collecting'
|
|
67
|
+
) {
|
|
68
|
+
dispatch({ type: 'CHECK' });
|
|
69
|
+
// After dispatch, status will change synchronously in the reducer
|
|
70
|
+
// but the new state won't be reflected until next render.
|
|
71
|
+
// We need to check the pending result in the next effect cycle.
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Handle result after status update
|
|
76
|
+
if (status === 'correct') {
|
|
77
|
+
notifiedRef.current = true;
|
|
78
|
+
onVerified();
|
|
79
|
+
} else if (status === 'incorrect') {
|
|
80
|
+
notifiedRef.current = true;
|
|
81
|
+
onFailed?.(state.attempts);
|
|
82
|
+
}
|
|
83
|
+
}, [selected.length, gridData.correctIndices.length, status, state.attempts, onVerified, onFailed]);
|
|
84
|
+
|
|
85
|
+
const handleRetry = () => {
|
|
86
|
+
notifiedRef.current = false;
|
|
87
|
+
setRetryCount((c) => c + 1);
|
|
88
|
+
dispatch({
|
|
89
|
+
type: 'RESET',
|
|
90
|
+
realWords,
|
|
91
|
+
gridSize: grid,
|
|
92
|
+
randomSeed: baseSeed + retryCount + 1,
|
|
93
|
+
allWords,
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<div>
|
|
99
|
+
<div data-testid="progress">
|
|
100
|
+
Select word {selected.length + 1} of {realWords.length}
|
|
101
|
+
</div>
|
|
102
|
+
<ol data-testid="grid">
|
|
103
|
+
{gridData.words.map((word, i) => {
|
|
104
|
+
const isSelected = selected.includes(i);
|
|
105
|
+
const isDisabled =
|
|
106
|
+
isSelected || status === 'correct' || status === 'incorrect';
|
|
107
|
+
return (
|
|
108
|
+
<li key={i}>
|
|
109
|
+
<button
|
|
110
|
+
data-testid={`cell-${i}`}
|
|
111
|
+
data-word={word}
|
|
112
|
+
disabled={isDisabled}
|
|
113
|
+
onClick={() => handleSelect(i)}
|
|
114
|
+
>
|
|
115
|
+
{word}
|
|
116
|
+
</button>
|
|
117
|
+
</li>
|
|
118
|
+
);
|
|
119
|
+
})}
|
|
120
|
+
</ol>
|
|
121
|
+
{status === 'incorrect' && (
|
|
122
|
+
<button data-testid="retry" onClick={handleRetry}>
|
|
123
|
+
Try again
|
|
124
|
+
</button>
|
|
125
|
+
)}
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react';
|
|
2
|
+
import type { Identity } from '@me2em/core';
|
|
3
|
+
|
|
4
|
+
export interface Me2emContextValue {
|
|
5
|
+
identity: Identity | null;
|
|
6
|
+
hasIdentity: boolean;
|
|
7
|
+
activateIdentity: (seed: Uint8Array) => Promise<boolean>;
|
|
8
|
+
clearIdentity: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const Me2emContext = createContext<Me2emContextValue | null>(null);
|
|
12
|
+
|
|
13
|
+
export function useMe2emContext(): Me2emContextValue {
|
|
14
|
+
const ctx = useContext(Me2emContext);
|
|
15
|
+
if (!ctx) {
|
|
16
|
+
throw new Error('Me2em hooks must be used inside <Me2emProvider>');
|
|
17
|
+
}
|
|
18
|
+
return ctx;
|
|
19
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export { Me2emProvider } from './Me2emProvider.js';
|
|
2
|
+
export { useMe2emContext } from './context.js';
|
|
3
|
+
export { useCreateIdentity } from './useCreateIdentity.js';
|
|
4
|
+
export { useImportSeed } from './useImportSeed.js';
|
|
5
|
+
export { useHandle } from './useHandle.js';
|
|
6
|
+
export { useSubHandle } from './useSubHandle.js';
|
|
7
|
+
export { SeedPhraseDisplay } from './SeedPhraseDisplay.js';
|
|
8
|
+
export type { SeedPhraseDisplayProps } from './SeedPhraseDisplay.js';
|
|
9
|
+
export { SeedPhraseVerify } from './SeedPhraseVerify.js';
|
|
10
|
+
export type { SeedPhraseVerifyProps } from './SeedPhraseVerify.js';
|
|
11
|
+
export { SeedPhraseImport } from './SeedPhraseImport.js';
|
|
12
|
+
export type { SeedPhraseImportProps } from './SeedPhraseImport.js';
|
|
13
|
+
export { PassphraseInput } from './PassphraseInput.js';
|
|
14
|
+
export type { PassphraseInputProps } from './PassphraseInput.js';
|
|
15
|
+
export { useIdentityContext } from './useIdentityContext.js';
|
|
16
|
+
export type {
|
|
17
|
+
UseIdentityContextResult,
|
|
18
|
+
} from './useIdentityContext.js';
|
|
19
|
+
export { useSession } from './useSession.js';
|
|
20
|
+
export type {
|
|
21
|
+
UseSessionOptions,
|
|
22
|
+
UseSessionResult,
|
|
23
|
+
} from './useSession.js';
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// packages/react/src/react/useCreateIdentity.ts
|
|
2
|
+
import {
|
|
3
|
+
useReducer,
|
|
4
|
+
useCallback,
|
|
5
|
+
type Reducer,
|
|
6
|
+
} from 'react';
|
|
7
|
+
import {
|
|
8
|
+
generateSeedPhrase,
|
|
9
|
+
get32ByteSeedFromMnemonic,
|
|
10
|
+
type SeedStrength,
|
|
11
|
+
type Identity,
|
|
12
|
+
} from '@me2em/core';
|
|
13
|
+
import {
|
|
14
|
+
identityFlowReducer,
|
|
15
|
+
initialIdentityFlowState,
|
|
16
|
+
} from '../headless/identity-flow.js';
|
|
17
|
+
import type { IdentityFlowState, IdentityFlowEvent, IdentityFlowStatus } from '../headless/types.js';
|
|
18
|
+
import { useMe2emContext } from './context.js';
|
|
19
|
+
|
|
20
|
+
export interface UseCreateIdentityResult {
|
|
21
|
+
status: IdentityFlowStatus;
|
|
22
|
+
seedWords: string[] | null;
|
|
23
|
+
identity: Identity | null;
|
|
24
|
+
error: string | null;
|
|
25
|
+
generate: (opts?: { strength?: SeedStrength }) => void;
|
|
26
|
+
confirmWords: () => Promise<void>;
|
|
27
|
+
reset: () => void;
|
|
28
|
+
fail: (message: string) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function useCreateIdentity(): UseCreateIdentityResult {
|
|
32
|
+
const { activateIdentity, clearIdentity, identity } = useMe2emContext();
|
|
33
|
+
const [state, dispatch] = useReducer<
|
|
34
|
+
Reducer<IdentityFlowState, IdentityFlowEvent>
|
|
35
|
+
>(identityFlowReducer, initialIdentityFlowState);
|
|
36
|
+
|
|
37
|
+
const generate = useCallback(
|
|
38
|
+
(opts?: { strength?: SeedStrength }) => {
|
|
39
|
+
const words = generateSeedPhrase(opts?.strength ?? 128);
|
|
40
|
+
get32ByteSeedFromMnemonic(words).then((derivedSeed) => {
|
|
41
|
+
dispatch({ type: 'GENERATE', words, seed: derivedSeed });
|
|
42
|
+
}).catch(() => {
|
|
43
|
+
dispatch({ type: 'FAIL', message: 'Failed to generate seed' });
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
[]
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const confirmWords = useCallback(async () => {
|
|
50
|
+
if (state.status !== 'seed-generated' || !state.identitySeed) return;
|
|
51
|
+
dispatch({ type: 'CONFIRM_WORDS' });
|
|
52
|
+
const ok = await activateIdentity(state.identitySeed);
|
|
53
|
+
if (!ok) {
|
|
54
|
+
dispatch({ type: 'FAIL', message: 'Identity activation failed' });
|
|
55
|
+
}
|
|
56
|
+
}, [state.status, state.identitySeed, activateIdentity]);
|
|
57
|
+
|
|
58
|
+
const reset = useCallback(() => {
|
|
59
|
+
dispatch({ type: 'RESET' });
|
|
60
|
+
clearIdentity();
|
|
61
|
+
}, [clearIdentity]);
|
|
62
|
+
|
|
63
|
+
const fail = useCallback((message: string) => {
|
|
64
|
+
dispatch({ type: 'FAIL', message });
|
|
65
|
+
}, []);
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
status: state.status,
|
|
69
|
+
seedWords: state.seedWords,
|
|
70
|
+
identity,
|
|
71
|
+
error: state.error,
|
|
72
|
+
generate,
|
|
73
|
+
confirmWords,
|
|
74
|
+
reset,
|
|
75
|
+
fail,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// packages/react/src/react/useHandle.ts
|
|
2
|
+
import { useState, useEffect } from 'react';
|
|
3
|
+
import { type Handle } from '@me2em/core';
|
|
4
|
+
import { useMe2emContext } from './context.js';
|
|
5
|
+
|
|
6
|
+
export function useHandle(name: string): Handle | null {
|
|
7
|
+
const { identity } = useMe2emContext();
|
|
8
|
+
const [handle, setHandle] = useState<Handle | null>(null);
|
|
9
|
+
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
let cancelled = false;
|
|
12
|
+
if (!identity) {
|
|
13
|
+
setHandle(null);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
// Core performs full name canonicalization (NFKC, lowercase,
|
|
17
|
+
// trim) and validation — no local normalization here.
|
|
18
|
+
identity.deriveHandle(name).then((h) => {
|
|
19
|
+
if (!cancelled) setHandle(h);
|
|
20
|
+
}).catch((err) => {
|
|
21
|
+
if (!cancelled) console.error('useHandle derivation failed', err);
|
|
22
|
+
});
|
|
23
|
+
return () => { cancelled = true; };
|
|
24
|
+
}, [identity, name]);
|
|
25
|
+
|
|
26
|
+
return handle;
|
|
27
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// packages/react/src/react/useIdentityContext.ts
|
|
2
|
+
import { useState, useCallback, useEffect, useRef } from 'react';
|
|
3
|
+
import type { IdentityContextStorage, IdentityContextData } from '../headless/identity-context-types.js';
|
|
4
|
+
import { useMe2emContext } from './context.js';
|
|
5
|
+
|
|
6
|
+
function publicKeyToId(publicKey: Uint8Array): string {
|
|
7
|
+
return Array.from(publicKey)
|
|
8
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
9
|
+
.join('');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface UseIdentityContextResult {
|
|
13
|
+
context: IdentityContextData | null;
|
|
14
|
+
isLoaded: boolean;
|
|
15
|
+
error: string | null;
|
|
16
|
+
save: (data: IdentityContextData) => Promise<void>;
|
|
17
|
+
clear: () => Promise<void>;
|
|
18
|
+
/** Reload the cache for the current identity (after sync). */
|
|
19
|
+
refresh: () => Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function useIdentityContext(
|
|
23
|
+
storage: IdentityContextStorage
|
|
24
|
+
): UseIdentityContextResult {
|
|
25
|
+
const { identity } = useMe2emContext();
|
|
26
|
+
const [context, setContext] = useState<IdentityContextData | null>(null);
|
|
27
|
+
const [isLoaded, setIsLoaded] = useState(false);
|
|
28
|
+
const [error, setError] = useState<string | null>(null);
|
|
29
|
+
|
|
30
|
+
const prevIdentityIdRef = useRef<string | null>(null);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
let cancelled = false;
|
|
34
|
+
|
|
35
|
+
if (!identity) {
|
|
36
|
+
setContext(null);
|
|
37
|
+
setIsLoaded(true);
|
|
38
|
+
setError(null);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const identityId = publicKeyToId(identity.getPublicKey());
|
|
43
|
+
|
|
44
|
+
// If identity changed, close old storage and open new
|
|
45
|
+
if (prevIdentityIdRef.current !== null && prevIdentityIdRef.current !== identityId) {
|
|
46
|
+
storage.close().catch(() => { /* ignore close errors */ });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
prevIdentityIdRef.current = identityId;
|
|
50
|
+
|
|
51
|
+
// Open storage for this identity and load cached context (atomic)
|
|
52
|
+
storage.openAndLoad(identityId)
|
|
53
|
+
.then((data) => {
|
|
54
|
+
if (cancelled) return;
|
|
55
|
+
setContext(data);
|
|
56
|
+
setIsLoaded(true);
|
|
57
|
+
setError(null);
|
|
58
|
+
})
|
|
59
|
+
.catch((err) => {
|
|
60
|
+
if (cancelled) return;
|
|
61
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
62
|
+
setIsLoaded(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return () => {
|
|
66
|
+
cancelled = true;
|
|
67
|
+
};
|
|
68
|
+
}, [identity, storage]);
|
|
69
|
+
|
|
70
|
+
const save = useCallback(async (data: IdentityContextData): Promise<void> => {
|
|
71
|
+
await storage.save(data);
|
|
72
|
+
setContext(data);
|
|
73
|
+
}, [storage]);
|
|
74
|
+
|
|
75
|
+
const clear = useCallback(async (): Promise<void> => {
|
|
76
|
+
await storage.clear();
|
|
77
|
+
setContext(null);
|
|
78
|
+
}, [storage]);
|
|
79
|
+
|
|
80
|
+
const refresh = useCallback(async (): Promise<void> => {
|
|
81
|
+
if (!identity) return;
|
|
82
|
+
const identityId = publicKeyToId(identity.getPublicKey());
|
|
83
|
+
const data = await storage.openAndLoad(identityId);
|
|
84
|
+
setContext(data);
|
|
85
|
+
}, [identity, storage]);
|
|
86
|
+
|
|
87
|
+
return { context, isLoaded, error, save, clear, refresh };
|
|
88
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// packages/react/src/react/useImportSeed.ts
|
|
2
|
+
import { useReducer, useCallback, type Reducer } from 'react';
|
|
3
|
+
import {
|
|
4
|
+
validateSeedPhrase,
|
|
5
|
+
get32ByteSeedFromMnemonic,
|
|
6
|
+
type Identity,
|
|
7
|
+
} from '@me2em/core';
|
|
8
|
+
import {
|
|
9
|
+
identityFlowReducer,
|
|
10
|
+
initialIdentityFlowState,
|
|
11
|
+
} from '../headless/identity-flow.js';
|
|
12
|
+
import type { IdentityFlowState, IdentityFlowEvent, IdentityFlowStatus } from '../headless/types.js';
|
|
13
|
+
import { useMe2emContext } from './context.js';
|
|
14
|
+
|
|
15
|
+
export interface UseImportSeedResult {
|
|
16
|
+
status: IdentityFlowStatus;
|
|
17
|
+
identity: Identity | null;
|
|
18
|
+
error: string | null;
|
|
19
|
+
importWords: (words: string[], passphrase?: string) => Promise<void>;
|
|
20
|
+
reset: () => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function useImportSeed(): UseImportSeedResult {
|
|
24
|
+
const { activateIdentity, clearIdentity, identity } = useMe2emContext();
|
|
25
|
+
const [state, dispatch] = useReducer<
|
|
26
|
+
Reducer<IdentityFlowState, IdentityFlowEvent>
|
|
27
|
+
>(identityFlowReducer, initialIdentityFlowState);
|
|
28
|
+
|
|
29
|
+
const importWords = useCallback(
|
|
30
|
+
async (words: string[], passphrase?: string) => {
|
|
31
|
+
const validation = validateSeedPhrase(words);
|
|
32
|
+
if (!validation.isValid) {
|
|
33
|
+
dispatch({ type: 'FAIL', message: validation.error ?? 'Invalid seed phrase' });
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
const seed = await get32ByteSeedFromMnemonic(words, passphrase ?? '');
|
|
38
|
+
dispatch({ type: 'IMPORT', seed });
|
|
39
|
+
await activateIdentity(seed);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
const message = err instanceof Error ? err.message : 'Import failed';
|
|
42
|
+
dispatch({ type: 'FAIL', message });
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
[activateIdentity]
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const reset = useCallback(() => {
|
|
49
|
+
dispatch({ type: 'RESET' });
|
|
50
|
+
clearIdentity();
|
|
51
|
+
}, [clearIdentity]);
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
status: state.status,
|
|
55
|
+
identity,
|
|
56
|
+
error: state.error,
|
|
57
|
+
importWords,
|
|
58
|
+
reset,
|
|
59
|
+
};
|
|
60
|
+
}
|