@flow-industries/id 0.21.2 → 0.22.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/README.md +52 -0
- package/contracts/v1/openapi.json +818 -0
- package/contracts/v1/reports.json +188 -0
- package/contracts/v1/sdk-exports.json +1165 -0
- package/dist/sdk/client/create-flow.js +2 -5
- package/dist/sdk/client/dialog-host.js +11 -6
- package/dist/sdk/client/flow-widget.js +3 -3
- package/dist/sdk/client/focus-session.d.ts +18 -0
- package/dist/sdk/client/focus-session.js +140 -0
- package/dist/sdk/client/focus.d.ts +11 -0
- package/dist/sdk/client/focus.js +30 -0
- package/dist/sdk/client/iframe-host.d.ts +2 -1
- package/dist/sdk/client/iframe-host.js +11 -1
- package/dist/sdk/client/index.d.ts +4 -1
- package/dist/sdk/client/index.js +2 -0
- package/dist/sdk/client/json-api.d.ts +1 -1
- package/dist/sdk/client/json-api.js +3 -1
- package/dist/sdk/client/open-profile.js +10 -7
- package/dist/sdk/client/profile-button.js +16 -9
- package/dist/sdk/client/reports.d.ts +9 -0
- package/dist/sdk/client/reports.js +30 -0
- package/dist/sdk/client/rooms.js +7 -26
- package/dist/sdk/client/study-search.d.ts +20 -0
- package/dist/sdk/client/study-search.js +116 -0
- package/dist/sdk/contracts/http.d.ts +155 -0
- package/dist/sdk/contracts/http.js +91 -0
- package/dist/sdk/contracts/reports.d.ts +130 -0
- package/dist/sdk/contracts/reports.js +67 -0
- package/dist/sdk/dialog/remote/Messenger.d.ts +3 -0
- package/dist/sdk/dialog/remote/Messenger.js +9 -3
- package/dist/sdk/react/use-cooldown.d.ts +1 -0
- package/dist/sdk/react/use-cooldown.js +12 -0
- package/dist/sdk/types/auth.d.ts +12 -0
- package/dist/sdk/types/dialog.d.ts +25 -0
- package/dist/sdk/types/focus.d.ts +109 -0
- package/dist/sdk/types/focus.js +0 -0
- package/dist/sdk/types/index.d.ts +8 -3
- package/dist/sdk/types/landing.d.ts +9 -0
- package/dist/sdk/types/landing.js +0 -0
- package/dist/sdk/types/otp.d.ts +20 -0
- package/dist/sdk/types/otp.js +0 -0
- package/dist/sdk/types/reports.d.ts +7 -0
- package/dist/sdk/types/reports.js +0 -0
- package/dist/sdk/types/settings.d.ts +162 -0
- package/dist/sdk/types/settings.js +0 -0
- package/dist/sdk/types/xp.d.ts +8 -0
- package/package.json +21 -9
|
@@ -13,11 +13,8 @@ import { createStore, initialFlowState } from "./store";
|
|
|
13
13
|
* see the result instead of racing it. Falls back to running `fn` directly
|
|
14
14
|
* where Web Locks is unavailable.
|
|
15
15
|
*/
|
|
16
|
-
function withOriginLock(name, fn) {
|
|
17
|
-
const locks =
|
|
18
|
-
/* SAFETY: the SDK stashes its singleton on globalThis so two bundles of it share one
|
|
19
|
-
instance; the property is namespaced and written only here. */
|
|
20
|
-
globalThis.navigator?.locks;
|
|
16
|
+
async function withOriginLock(name, fn) {
|
|
17
|
+
const locks = globalThis.navigator?.locks;
|
|
21
18
|
if (locks?.request)
|
|
22
19
|
return locks.request(name, fn);
|
|
23
20
|
return fn();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { bridgeToWindow, makeIframe } from "./iframe-host";
|
|
1
|
+
import { bridgeToWindow, makeIframe, sendBridgeNotification, } from "./iframe-host";
|
|
2
2
|
import { setOverlayFocus } from "./overlay-focus";
|
|
3
3
|
const HIDDEN_STYLE = {
|
|
4
4
|
position: "fixed",
|
|
@@ -106,7 +106,7 @@ export function createDialogHost(options) {
|
|
|
106
106
|
return;
|
|
107
107
|
initSent = true;
|
|
108
108
|
// SAFETY: the payload map types this topic; the dialog reads exactly these fields.
|
|
109
|
-
messenger
|
|
109
|
+
sendBridgeNotification(messenger, "__internal", {
|
|
110
110
|
type: "init",
|
|
111
111
|
mode: "iframe",
|
|
112
112
|
referrer: opts?.referrer ?? { title: document.title },
|
|
@@ -124,7 +124,7 @@ export function createDialogHost(options) {
|
|
|
124
124
|
// widget, so every dialog appears and disappears identically.
|
|
125
125
|
void (
|
|
126
126
|
// SAFETY: the payload map types this topic; the dialog reads exactly these fields.
|
|
127
|
-
messenger
|
|
127
|
+
sendBridgeNotification(messenger, "__internal", { type: "dialog-shown" }));
|
|
128
128
|
}
|
|
129
129
|
function hide() {
|
|
130
130
|
if (!iframe)
|
|
@@ -137,7 +137,7 @@ export function createDialogHost(options) {
|
|
|
137
137
|
setOverlayFocus(iframe, false);
|
|
138
138
|
void (
|
|
139
139
|
// SAFETY: the payload map types this topic; the dialog reads exactly these fields.
|
|
140
|
-
messenger
|
|
140
|
+
sendBridgeNotification(messenger, "__internal", { type: "dialog-hidden" }));
|
|
141
141
|
}
|
|
142
142
|
function open(opts) {
|
|
143
143
|
if (opts?.mode === "popup") {
|
|
@@ -177,9 +177,14 @@ export function createDialogHost(options) {
|
|
|
177
177
|
return new Promise((resolve, reject) => {
|
|
178
178
|
pending.set(id, { resolve, reject });
|
|
179
179
|
// SAFETY: the rpc-requests payload shape.
|
|
180
|
-
messenger
|
|
180
|
+
messenger
|
|
181
|
+
.send("rpc-requests", [
|
|
181
182
|
{ request: rpcRequest, status: "pending" },
|
|
182
|
-
])
|
|
183
|
+
])
|
|
184
|
+
.catch((error) => {
|
|
185
|
+
pending.delete(id);
|
|
186
|
+
reject(error);
|
|
187
|
+
});
|
|
183
188
|
});
|
|
184
189
|
}
|
|
185
190
|
/** Opens the dialog UI and dispatches an interactive RPC (login, sign, etc.). */
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { resolveIdHost } from "../id-host";
|
|
2
2
|
import { getFlow, requireFlow } from "./create-flow";
|
|
3
|
-
import { answerTokenRequest, bridgeToWindow, makeIframe } from "./iframe-host";
|
|
3
|
+
import { answerTokenRequest, bridgeToWindow, makeIframe, sendBridgeNotification, } from "./iframe-host";
|
|
4
4
|
/**
|
|
5
5
|
* The inline widgets an embedder may mount, mapped to the dialog routes that
|
|
6
6
|
* serve them. Callers name a widget rather than passing a route so the dialog's
|
|
@@ -65,7 +65,7 @@ export function createFlowWidget(options) {
|
|
|
65
65
|
// confirms where the frame is; what the handshake is really for is telling
|
|
66
66
|
// the dialog which origin it is embedded by, which is what it later asks for
|
|
67
67
|
// a token.
|
|
68
|
-
|
|
68
|
+
sendBridgeNotification(bridge, "__internal", {
|
|
69
69
|
type: "init",
|
|
70
70
|
mode: "iframe",
|
|
71
71
|
referrer: { title: document.title },
|
|
@@ -81,7 +81,7 @@ export function createFlowWidget(options) {
|
|
|
81
81
|
if (next === theme)
|
|
82
82
|
return;
|
|
83
83
|
theme = next;
|
|
84
|
-
|
|
84
|
+
sendBridgeNotification(bridge, "__internal", {
|
|
85
85
|
type: "set-theme",
|
|
86
86
|
theme: { colorScheme: next },
|
|
87
87
|
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ActiveAction, ClockAnchor, FocusSessionEvent, FocusSessionState } from "../types";
|
|
2
|
+
/** A re-read only replaces the anchor when it moves the display by more than
|
|
3
|
+
* this, so request timing never makes the clock jitter. */
|
|
4
|
+
export declare const REANCHOR_TOLERANCE_SECONDS = 2;
|
|
5
|
+
export declare function anchorFor(active: ActiveAction, readAt: number): ClockAnchor;
|
|
6
|
+
export declare function elapsedSeconds(anchor: ClockAnchor, now: number): number;
|
|
7
|
+
/** Whether a fresh server sample should replace the current anchor: yes when
|
|
8
|
+
* the pause state flipped or the server disagrees with the extrapolated clock
|
|
9
|
+
* by more than the tolerance (another surface paused, a replaced session). */
|
|
10
|
+
export declare function shouldReanchor(current: ClockAnchor, next: ActiveAction, now: number): boolean;
|
|
11
|
+
/** `mm:ss`, rolling into `h:mm:ss` past an hour. */
|
|
12
|
+
export declare function formatElapsed(seconds: number): string;
|
|
13
|
+
export declare const INITIAL_FOCUS_STATE: FocusSessionState;
|
|
14
|
+
export declare function focusSessionReducer(state: FocusSessionState, event: FocusSessionEvent): FocusSessionState;
|
|
15
|
+
/** The pill and panel headline: "Meditate", or "Practice · Chinese" when a
|
|
16
|
+
* subject is attached. `fallback` covers a source the catalog no longer
|
|
17
|
+
* labels. */
|
|
18
|
+
export declare function actionHeadline(action: Pick<ActiveAction, "label" | "source" | "subject">, fallback: string): string;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/** A re-read only replaces the anchor when it moves the display by more than
|
|
2
|
+
* this, so request timing never makes the clock jitter. */
|
|
3
|
+
export const REANCHOR_TOLERANCE_SECONDS = 2;
|
|
4
|
+
export function anchorFor(active, readAt) {
|
|
5
|
+
return {
|
|
6
|
+
accruedSeconds: active.accruedSeconds,
|
|
7
|
+
paused: active.paused,
|
|
8
|
+
readAt,
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
export function elapsedSeconds(anchor, now) {
|
|
12
|
+
if (anchor.paused)
|
|
13
|
+
return anchor.accruedSeconds;
|
|
14
|
+
return anchor.accruedSeconds + Math.max(0, now - anchor.readAt) / 1000;
|
|
15
|
+
}
|
|
16
|
+
/** Whether a fresh server sample should replace the current anchor: yes when
|
|
17
|
+
* the pause state flipped or the server disagrees with the extrapolated clock
|
|
18
|
+
* by more than the tolerance (another surface paused, a replaced session). */
|
|
19
|
+
export function shouldReanchor(current, next, now) {
|
|
20
|
+
if (current.paused !== next.paused)
|
|
21
|
+
return true;
|
|
22
|
+
return (Math.abs(elapsedSeconds(current, now) - next.accruedSeconds) >
|
|
23
|
+
REANCHOR_TOLERANCE_SECONDS);
|
|
24
|
+
}
|
|
25
|
+
/** `mm:ss`, rolling into `h:mm:ss` past an hour. */
|
|
26
|
+
export function formatElapsed(seconds) {
|
|
27
|
+
const whole = Math.max(0, Math.floor(seconds));
|
|
28
|
+
const hours = Math.floor(whole / 3600);
|
|
29
|
+
const minutes = Math.floor((whole % 3600) / 60);
|
|
30
|
+
const secs = whole % 60;
|
|
31
|
+
const mmss = `${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
|
|
32
|
+
return hours > 0 ? `${hours}:${mmss}` : mmss;
|
|
33
|
+
}
|
|
34
|
+
export const INITIAL_FOCUS_STATE = {
|
|
35
|
+
phase: "unknown",
|
|
36
|
+
active: null,
|
|
37
|
+
anchor: null,
|
|
38
|
+
finished: null,
|
|
39
|
+
conflict: null,
|
|
40
|
+
busy: false,
|
|
41
|
+
failed: false,
|
|
42
|
+
};
|
|
43
|
+
function withActive(state, active, at) {
|
|
44
|
+
const sameSession = state.active?.sessionId === active.sessionId;
|
|
45
|
+
const anchor = sameSession && state.anchor && !shouldReanchor(state.anchor, active, at)
|
|
46
|
+
? state.anchor
|
|
47
|
+
: anchorFor(active, at);
|
|
48
|
+
return {
|
|
49
|
+
...state,
|
|
50
|
+
phase: "active",
|
|
51
|
+
active,
|
|
52
|
+
anchor,
|
|
53
|
+
conflict: null,
|
|
54
|
+
failed: false,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export function focusSessionReducer(state, event) {
|
|
58
|
+
switch (event.type) {
|
|
59
|
+
case "read": {
|
|
60
|
+
if (event.active === null) {
|
|
61
|
+
return {
|
|
62
|
+
...state,
|
|
63
|
+
phase: "idle",
|
|
64
|
+
active: null,
|
|
65
|
+
anchor: null,
|
|
66
|
+
failed: false,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return withActive(state, event.active, event.at);
|
|
70
|
+
}
|
|
71
|
+
case "read-failed":
|
|
72
|
+
return { ...state, failed: true };
|
|
73
|
+
case "request":
|
|
74
|
+
return { ...state, busy: true, failed: false };
|
|
75
|
+
case "request-failed":
|
|
76
|
+
return { ...state, busy: false, failed: true };
|
|
77
|
+
case "started":
|
|
78
|
+
return {
|
|
79
|
+
...withActive(state, event.session, event.at),
|
|
80
|
+
anchor: anchorFor(event.session, event.at),
|
|
81
|
+
busy: false,
|
|
82
|
+
finished: null,
|
|
83
|
+
};
|
|
84
|
+
case "conflict":
|
|
85
|
+
return { ...state, busy: false, conflict: event.active };
|
|
86
|
+
case "paused": {
|
|
87
|
+
if (!state.active || !state.anchor)
|
|
88
|
+
return { ...state, busy: false };
|
|
89
|
+
const frozen = elapsedSeconds(state.anchor, event.at);
|
|
90
|
+
return {
|
|
91
|
+
...state,
|
|
92
|
+
busy: false,
|
|
93
|
+
active: { ...state.active, paused: true, accruedSeconds: frozen },
|
|
94
|
+
anchor: { accruedSeconds: frozen, paused: true, readAt: event.at },
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
case "resumed": {
|
|
98
|
+
if (!state.active || !state.anchor)
|
|
99
|
+
return { ...state, busy: false };
|
|
100
|
+
return {
|
|
101
|
+
...state,
|
|
102
|
+
busy: false,
|
|
103
|
+
active: { ...state.active, paused: false },
|
|
104
|
+
anchor: { ...state.anchor, paused: false, readAt: event.at },
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
case "finished": {
|
|
108
|
+
const finished = state.active
|
|
109
|
+
? {
|
|
110
|
+
source: state.active.source,
|
|
111
|
+
label: state.active.label,
|
|
112
|
+
subject: state.active.subject,
|
|
113
|
+
xpGranted: event.result.xpGranted,
|
|
114
|
+
durationSeconds: event.result.durationSeconds,
|
|
115
|
+
}
|
|
116
|
+
: null;
|
|
117
|
+
return {
|
|
118
|
+
...state,
|
|
119
|
+
phase: "idle",
|
|
120
|
+
active: null,
|
|
121
|
+
anchor: null,
|
|
122
|
+
busy: false,
|
|
123
|
+
finished,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
case "dismiss-finished":
|
|
127
|
+
return { ...state, finished: null };
|
|
128
|
+
case "dismiss-conflict":
|
|
129
|
+
return { ...state, conflict: null };
|
|
130
|
+
case "reset":
|
|
131
|
+
return INITIAL_FOCUS_STATE;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/** The pill and panel headline: "Meditate", or "Practice · Chinese" when a
|
|
135
|
+
* subject is attached. `fallback` covers a source the catalog no longer
|
|
136
|
+
* labels. */
|
|
137
|
+
export function actionHeadline(action, fallback) {
|
|
138
|
+
const label = action.label ?? fallback;
|
|
139
|
+
return action.subject ? `${label} · ${action.subject.name}` : label;
|
|
140
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { FocusApi, UserActionSurface } from "../types";
|
|
2
|
+
export type { ActionCatalogEntry, ActionCatalogResponse, ActionCurve, ActionEventRequest, ActionEventResponse, ActionSubjectRef, ActionTransitionKind, ActiveAction, CatalogFieldRef, ClockAnchor, CreateStudySubjectInput, FinishActionRequest, FinishActionResponse, FinishedSummary, FocusApi, FocusPhase, FocusSessionEvent, FocusSessionState, ListStudySessionsOptions, ListStudySubjectsOptions, StartActionResult, StartFocusRequest, StudyCatalog, StudyCategory, StudyField, StudyRange, StudySearchHit, StudySessionPage, StudySubject, StudySubjectList, StudySubjectRemovalResponse, StudySubjectResponse, StudySummary, UpdateStudySubjectInput, UserActionSurface, XpEstimateContext, } from "../types";
|
|
3
|
+
export { estimateActionXp, flowMultiplier } from "../xp/curve";
|
|
4
|
+
export { ActionsRequestError, isActiveSessionConflict } from "./actions";
|
|
5
|
+
export * from "./focus-session";
|
|
6
|
+
export { StudyRequestError } from "./study";
|
|
7
|
+
export * from "./study-search";
|
|
8
|
+
export declare const MEDITATION_SOURCE = "game.action.meditation";
|
|
9
|
+
export declare const STUDY_SOURCE = "action.study";
|
|
10
|
+
/** Compose action and study requests using the host's credentials and surface. */
|
|
11
|
+
export declare function createFocusApi(host: string, getToken: () => Promise<string | null>, surface: UserActionSurface): FocusApi;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { createActionsApi } from "./actions";
|
|
2
|
+
import { createStudyApi } from "./study";
|
|
3
|
+
export { estimateActionXp, flowMultiplier } from "../xp/curve";
|
|
4
|
+
export { ActionsRequestError, isActiveSessionConflict } from "./actions";
|
|
5
|
+
export * from "./focus-session";
|
|
6
|
+
export { StudyRequestError } from "./study";
|
|
7
|
+
export * from "./study-search";
|
|
8
|
+
export const MEDITATION_SOURCE = "game.action.meditation";
|
|
9
|
+
export const STUDY_SOURCE = "action.study";
|
|
10
|
+
/** Compose action and study requests using the host's credentials and surface. */
|
|
11
|
+
export function createFocusApi(host, getToken, surface) {
|
|
12
|
+
const actions = createActionsApi(host, getToken);
|
|
13
|
+
const study = createStudyApi(host, getToken);
|
|
14
|
+
return {
|
|
15
|
+
catalog: actions.catalog,
|
|
16
|
+
active: actions.me,
|
|
17
|
+
start(request) {
|
|
18
|
+
return actions.start({ ...request, surface });
|
|
19
|
+
},
|
|
20
|
+
event: actions.event,
|
|
21
|
+
finish: actions.finish,
|
|
22
|
+
studyCatalog: study.catalog,
|
|
23
|
+
subjects: study.subjects,
|
|
24
|
+
createSubject: study.createSubject,
|
|
25
|
+
updateSubject: study.updateSubject,
|
|
26
|
+
deleteSubject: study.deleteSubject,
|
|
27
|
+
summary: study.summary,
|
|
28
|
+
sessions: study.sessions,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as Messenger from "../dialog/remote/Messenger";
|
|
2
|
-
import type { Flow, ProfileIdentity } from "../types";
|
|
2
|
+
import type { Flow, Payload, ProfileIdentity, Topic } from "../types";
|
|
3
3
|
/**
|
|
4
4
|
* Permissions the dialog iframe needs to run WebAuthn passkey ceremonies and
|
|
5
5
|
* copy recovery values. Shared by every Flow iframe host (the dialog host and
|
|
@@ -48,3 +48,4 @@ export declare function hostIdentity(flow: Flow): ProfileIdentity | null;
|
|
|
48
48
|
* out — instead of leaving the request to time out.
|
|
49
49
|
*/
|
|
50
50
|
export declare function answerTokenRequest(bridge: Messenger.Bridge, flow: Flow, id: string): Promise<void>;
|
|
51
|
+
export declare function sendBridgeNotification<T extends Topic>(bridge: Messenger.Bridge | null | undefined, topic: T, payload: Payload<T>): void;
|
|
@@ -71,5 +71,15 @@ export function hostIdentity(flow) {
|
|
|
71
71
|
*/
|
|
72
72
|
export async function answerTokenRequest(bridge, flow, id) {
|
|
73
73
|
const token = await flow.getToken().catch(() => null);
|
|
74
|
-
|
|
74
|
+
sendBridgeNotification(bridge, "__internal", {
|
|
75
|
+
type: "profile-token",
|
|
76
|
+
id,
|
|
77
|
+
token,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
export function sendBridgeNotification(bridge, topic, payload) {
|
|
81
|
+
void bridge?.send(topic, payload).catch((error) => {
|
|
82
|
+
if (!(error instanceof Messenger.BridgeDestroyedError))
|
|
83
|
+
throw error;
|
|
84
|
+
});
|
|
75
85
|
}
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
export { defaultIdHost, isLocalHostname } from "../id-host";
|
|
2
|
-
export type { AccessKeyOptions, ActionCatalogEntry, ActionCatalogResponse, ActionCurve, ActionEventRequest, ActionEventResponse, ActionSubjectRef, ActionSurface, ActionsApi, ActionTransitionKind, ActiveActionResponse, ActiveSessionConflict, AdditionalSession, Address, ConnectCapabilities, ConnectResponse, CreateFlowOptions, CreateStudySubjectInput, CurrentAction, DialogHost, FinishActionRequest, FinishActionResponse, Flow, FlowCredential, FlowSessionState, FlowState, FlowUser, FlowWidgetHandle, FlowWidgetName, ListStudySessionsOptions, ListStudySubjectsOptions, LoginOptions, MethodName, MountFlowWidgetOptions, MountProfileOptions, OpenProfileOptions, ProfileButtonHandle, ProfilePosition, RoomMemberEntry, Session, StartActionInput, StartActionResponse, StartActionResult, StudyApi, StudyCatalog, StudyCategory, StudyCategoryTotals, StudyField, StudyRange, StudySessionEntry, StudySessionPage, StudySubject, StudySubjectList, StudySubjectRefusal, StudySubjectRemovalResponse, StudySubjectResponse, StudySubjectTotals, StudySummary, UpdateStudySubjectInput, UserActionSurface, XpEstimateContext, XpMode, } from "../types";
|
|
2
|
+
export type { AccessKeyOptions, ActionCatalogEntry, ActionCatalogResponse, ActionCurve, ActionEventRequest, ActionEventResponse, ActionSubjectRef, ActionSurface, ActionsApi, ActionTransitionKind, ActiveAction, ActiveActionResponse, ActiveSessionConflict, AdditionalSession, Address, CatalogFieldRef, ClockAnchor, ConnectCapabilities, ConnectResponse, CreateFlowOptions, CreateStudySubjectInput, CurrentAction, DialogHost, FinishActionRequest, FinishActionResponse, FinishedSummary, Flow, FlowCredential, FlowSessionState, FlowState, FlowUser, FlowWidgetHandle, FlowWidgetName, FocusApi, FocusPhase, FocusSessionEvent, FocusSessionState, ListStudySessionsOptions, ListStudySubjectsOptions, LoginOptions, MethodName, MountFlowWidgetOptions, MountProfileOptions, OpenProfileOptions, ProfileButtonHandle, ProfilePosition, RoomMemberEntry, Session, StartActionInput, StartActionResponse, StartActionResult, StartFocusRequest, StudyApi, StudyCatalog, StudyCategory, StudyCategoryTotals, StudyField, StudyRange, StudySearchHit, StudySessionEntry, StudySessionPage, StudySubject, StudySubjectList, StudySubjectRefusal, StudySubjectRemovalResponse, StudySubjectResponse, StudySubjectTotals, StudySummary, UpdateStudySubjectInput, UserActionSurface, XpEstimateContext, XpMode, } from "../types";
|
|
3
3
|
export { consistencyCurve, estimateActionXp, FLOW_SCORE_MAX, flowMultiplier, } from "../xp/curve";
|
|
4
4
|
export { ActionsRequestError, createActionsApi, isActiveSessionConflict, } from "./actions";
|
|
5
5
|
export { createFlow, getFlow, requireFlow, resetFlow } from "./create-flow";
|
|
6
6
|
export { createDialogHost } from "./dialog-host";
|
|
7
7
|
export { createFlowWidget } from "./flow-widget";
|
|
8
|
+
export { actionHeadline, anchorFor, catalogFieldIndex, createFocusApi, elapsedSeconds, fieldsCoveredBySubjects, focusSessionReducer, formatElapsed, hitLabel, INITIAL_FOCUS_STATE, MEDITATION_SOURCE, matchScore, normalizeSearchText, REANCHOR_TOLERANCE_SECONDS, recentSubjects, STUDY_SOURCE, searchStudy, shouldReanchor, } from "./focus";
|
|
8
9
|
export { METHODS } from "./methods";
|
|
9
10
|
export { closeProfile, openProfile } from "./open-profile";
|
|
10
11
|
export { createProfileButton } from "./profile-button";
|
|
12
|
+
export type { ReportOperation, ReportRequest, ReportResponse } from "./reports";
|
|
13
|
+
export { createReportsApi, ReportRequestError } from "./reports";
|
|
11
14
|
export { createRoomsApi, RoomsRequestError } from "./rooms";
|
|
12
15
|
export { createStaticFlow } from "./static-flow";
|
|
13
16
|
export { createStudyApi, StudyRequestError } from "./study";
|
package/dist/sdk/client/index.js
CHANGED
|
@@ -4,9 +4,11 @@ export { ActionsRequestError, createActionsApi, isActiveSessionConflict, } from
|
|
|
4
4
|
export { createFlow, getFlow, requireFlow, resetFlow } from "./create-flow";
|
|
5
5
|
export { createDialogHost } from "./dialog-host";
|
|
6
6
|
export { createFlowWidget } from "./flow-widget";
|
|
7
|
+
export { actionHeadline, anchorFor, catalogFieldIndex, createFocusApi, elapsedSeconds, fieldsCoveredBySubjects, focusSessionReducer, formatElapsed, hitLabel, INITIAL_FOCUS_STATE, MEDITATION_SOURCE, matchScore, normalizeSearchText, REANCHOR_TOLERANCE_SECONDS, recentSubjects, STUDY_SOURCE, searchStudy, shouldReanchor, } from "./focus";
|
|
7
8
|
export { METHODS } from "./methods";
|
|
8
9
|
export { closeProfile, openProfile } from "./open-profile";
|
|
9
10
|
export { createProfileButton } from "./profile-button";
|
|
11
|
+
export { createReportsApi, ReportRequestError } from "./reports";
|
|
10
12
|
export { createRoomsApi, RoomsRequestError } from "./rooms";
|
|
11
13
|
export { createStaticFlow } from "./static-flow";
|
|
12
14
|
export { createStudyApi, StudyRequestError } from "./study";
|
|
@@ -18,7 +18,7 @@ export type JsonApiError = (status: number, message: string, reason?: string) =>
|
|
|
18
18
|
* raw status and payload for the few responses a client treats as values (a
|
|
19
19
|
* start's 409); `request` is the common path that throws on any non-2xx.
|
|
20
20
|
*/
|
|
21
|
-
export declare function createJsonApi(host: string, prefix: string, getToken: () => Promise<string | null>, makeError: JsonApiError): {
|
|
21
|
+
export declare function createJsonApi(host: string, prefix: string, getToken: () => Promise<string | null>, makeError: JsonApiError, mapFailure?: (exchange: JsonExchange) => Error): {
|
|
22
22
|
exchange: (path: string, options?: JsonRequestOptions) => Promise<JsonExchange>;
|
|
23
23
|
request: <T>(path: string, options?: JsonRequestOptions) => Promise<T>;
|
|
24
24
|
failure: (exchange: JsonExchange) => Error;
|
|
@@ -11,10 +11,12 @@ const failureSchema = z.object({
|
|
|
11
11
|
* raw status and payload for the few responses a client treats as values (a
|
|
12
12
|
* start's 409); `request` is the common path that throws on any non-2xx.
|
|
13
13
|
*/
|
|
14
|
-
export function createJsonApi(host, prefix, getToken, makeError) {
|
|
14
|
+
export function createJsonApi(host, prefix, getToken, makeError, mapFailure) {
|
|
15
15
|
/** The error a non-2xx exchange throws: the route's own wording when it
|
|
16
16
|
* sent one, and its machine code as `reason`. */
|
|
17
17
|
function failure(exchange) {
|
|
18
|
+
if (mapFailure)
|
|
19
|
+
return mapFailure(exchange);
|
|
18
20
|
const parsed = failureSchema.safeParse(exchange.payload);
|
|
19
21
|
const { error, reason } = parsed.success ? parsed.data : {};
|
|
20
22
|
return makeError(exchange.status, error ?? `Request failed (${exchange.status})`, reason ?? error);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { resolveIdHost } from "../id-host";
|
|
2
2
|
import { isValidUsername } from "../usernames";
|
|
3
3
|
import { getFlow, requireFlow } from "./create-flow";
|
|
4
|
-
import { answerTokenRequest, bridgeToWindow, hostIdentity, makeIframe, OVERLAY_STYLE, } from "./iframe-host";
|
|
4
|
+
import { answerTokenRequest, bridgeToWindow, hostIdentity, makeIframe, OVERLAY_STYLE, sendBridgeNotification, } from "./iframe-host";
|
|
5
5
|
import { setOverlayFocus } from "./overlay-focus";
|
|
6
6
|
// One overlay per page, kept for the page's lifetime. The alternative — a
|
|
7
7
|
// handle per call site — would put a second fullscreen iframe over the host the
|
|
@@ -47,7 +47,7 @@ function createViewer(options) {
|
|
|
47
47
|
// `/dialog/` and `init` asks it to land on the profile. Sending the route
|
|
48
48
|
// here rather than as a follow-up message keeps the sign-in root from ever
|
|
49
49
|
// mounting, even for the frame it takes to navigate away from it.
|
|
50
|
-
|
|
50
|
+
sendBridgeNotification(opened, "__internal", {
|
|
51
51
|
type: "init",
|
|
52
52
|
mode: "iframe",
|
|
53
53
|
referrer: { title: document.title },
|
|
@@ -90,11 +90,14 @@ function createViewer(options) {
|
|
|
90
90
|
// re-pointed in place. Tearing it down and rebuilding would reload the
|
|
91
91
|
// dialog and lose the open animation on every profile after the first.
|
|
92
92
|
if (mounted)
|
|
93
|
-
|
|
93
|
+
sendBridgeNotification(live, "__internal", {
|
|
94
|
+
type: "profile-view",
|
|
95
|
+
username,
|
|
96
|
+
});
|
|
94
97
|
// Pushed on every open rather than tracked with a subscription: it is only
|
|
95
98
|
// read while a profile is on screen, and sending it here means the "this is
|
|
96
99
|
// you" link is decided from the session as it stands at that moment.
|
|
97
|
-
|
|
100
|
+
sendBridgeNotification(live, "__internal", {
|
|
98
101
|
type: "profile-identity",
|
|
99
102
|
identity: hostIdentity(flow),
|
|
100
103
|
});
|
|
@@ -102,7 +105,7 @@ function createViewer(options) {
|
|
|
102
105
|
onClose = nextOnClose;
|
|
103
106
|
visible = true;
|
|
104
107
|
applyHitTesting();
|
|
105
|
-
|
|
108
|
+
sendBridgeNotification(live, "__internal", { type: "dialog-shown" });
|
|
106
109
|
}
|
|
107
110
|
// Visibility is hit-testing plus a message, and nothing else. The overlay
|
|
108
111
|
// iframe is NEVER set to `display: none` — that pauses the iframe's
|
|
@@ -116,7 +119,7 @@ function createViewer(options) {
|
|
|
116
119
|
visible = false;
|
|
117
120
|
window.removeEventListener("keydown", onKeyDown);
|
|
118
121
|
applyHitTesting();
|
|
119
|
-
|
|
122
|
+
sendBridgeNotification(bridge, "__internal", { type: "dialog-hidden" });
|
|
120
123
|
const notifyClose = onClose;
|
|
121
124
|
onClose = undefined;
|
|
122
125
|
notifyClose?.();
|
|
@@ -131,7 +134,7 @@ function createViewer(options) {
|
|
|
131
134
|
if (next === theme)
|
|
132
135
|
return;
|
|
133
136
|
theme = next;
|
|
134
|
-
|
|
137
|
+
sendBridgeNotification(bridge, "__internal", {
|
|
135
138
|
type: "set-theme",
|
|
136
139
|
theme: { colorScheme: next },
|
|
137
140
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { resolveIdHost } from "../id-host";
|
|
2
2
|
import { getFlow, requireFlow } from "./create-flow";
|
|
3
|
-
import { answerTokenRequest, bridgeToWindow, hostIdentity, makeIframe, OVERLAY_STYLE, } from "./iframe-host";
|
|
3
|
+
import { answerTokenRequest, bridgeToWindow, hostIdentity, makeIframe, OVERLAY_STYLE, sendBridgeNotification, } from "./iframe-host";
|
|
4
4
|
import { setOverlayFocus } from "./overlay-focus";
|
|
5
5
|
const POSITION_STYLE = {
|
|
6
6
|
"top-right": { top: "0", right: "0" },
|
|
@@ -62,7 +62,7 @@ export function createProfileButton(options) {
|
|
|
62
62
|
const bridge = bridgeToWindow(frame.contentWindow, {
|
|
63
63
|
targetOrigin: hostOrigin,
|
|
64
64
|
});
|
|
65
|
-
bridge
|
|
65
|
+
sendBridgeNotification(bridge, "__internal", {
|
|
66
66
|
type: "init",
|
|
67
67
|
mode: "iframe",
|
|
68
68
|
referrer: { title: document.title },
|
|
@@ -130,7 +130,7 @@ export function createProfileButton(options) {
|
|
|
130
130
|
void answerTokenRequest(dialogBridge, flow, payload.id);
|
|
131
131
|
}
|
|
132
132
|
});
|
|
133
|
-
|
|
133
|
+
sendBridgeNotification(dialogBridge, "__internal", {
|
|
134
134
|
type: "profile-identity",
|
|
135
135
|
identity: hostIdentity(flow),
|
|
136
136
|
});
|
|
@@ -147,14 +147,18 @@ export function createProfileButton(options) {
|
|
|
147
147
|
return;
|
|
148
148
|
dialog.style.pointerEvents = "auto";
|
|
149
149
|
setOverlayFocus(dialog, true);
|
|
150
|
-
|
|
150
|
+
sendBridgeNotification(dialogBridge, "__internal", {
|
|
151
|
+
type: "dialog-shown",
|
|
152
|
+
});
|
|
151
153
|
}
|
|
152
154
|
function hideDialog() {
|
|
153
155
|
if (!dialog)
|
|
154
156
|
return;
|
|
155
157
|
dialog.style.pointerEvents = "none";
|
|
156
158
|
setOverlayFocus(dialog, false);
|
|
157
|
-
|
|
159
|
+
sendBridgeNotification(dialogBridge, "__internal", {
|
|
160
|
+
type: "dialog-hidden",
|
|
161
|
+
});
|
|
158
162
|
}
|
|
159
163
|
// ----- keep both iframes' identity in sync with the Flow session -----
|
|
160
164
|
// flow.subscribe fires on any store change (jwt/credential/address too), so
|
|
@@ -166,9 +170,12 @@ export function createProfileButton(options) {
|
|
|
166
170
|
if (key === lastIdentityKey)
|
|
167
171
|
return;
|
|
168
172
|
lastIdentityKey = key;
|
|
169
|
-
|
|
173
|
+
sendBridgeNotification(pillBridge, "__internal", {
|
|
174
|
+
type: "profile-identity",
|
|
175
|
+
identity,
|
|
176
|
+
});
|
|
170
177
|
if (dialogBridge)
|
|
171
|
-
|
|
178
|
+
sendBridgeNotification(dialogBridge, "__internal", {
|
|
172
179
|
type: "profile-identity",
|
|
173
180
|
identity,
|
|
174
181
|
});
|
|
@@ -180,11 +187,11 @@ export function createProfileButton(options) {
|
|
|
180
187
|
if (next === theme)
|
|
181
188
|
return;
|
|
182
189
|
theme = next;
|
|
183
|
-
|
|
190
|
+
sendBridgeNotification(pillBridge, "__internal", {
|
|
184
191
|
type: "set-theme",
|
|
185
192
|
theme: { colorScheme: next },
|
|
186
193
|
});
|
|
187
|
-
|
|
194
|
+
sendBridgeNotification(dialogBridge, "__internal", {
|
|
188
195
|
type: "set-theme",
|
|
189
196
|
theme: { colorScheme: next },
|
|
190
197
|
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ReportsApi, ReportTransport } from "../types/reports";
|
|
2
|
+
export type { ReportOperation, ReportRequest, ReportResponse, } from "../types/reports";
|
|
3
|
+
export declare class ReportRequestError extends Error {
|
|
4
|
+
readonly status: number;
|
|
5
|
+
readonly response: string;
|
|
6
|
+
constructor(status: number, response: string);
|
|
7
|
+
}
|
|
8
|
+
/** Typed service reports. Keep the reporting bearer on trusted servers only. */
|
|
9
|
+
export declare function createReportsApi(host: string, token: string, transport?: ReportTransport): ReportsApi;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { httpOperations } from "../contracts/http";
|
|
2
|
+
export class ReportRequestError extends Error {
|
|
3
|
+
status;
|
|
4
|
+
response;
|
|
5
|
+
constructor(status, response) {
|
|
6
|
+
super(`Flow report failed (${status})`);
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.response = response;
|
|
9
|
+
this.name = "ReportRequestError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/** Typed service reports. Keep the reporting bearer on trusted servers only. */
|
|
13
|
+
export function createReportsApi(host, token, transport = fetch) {
|
|
14
|
+
return async function report(operation, body) {
|
|
15
|
+
const contract = httpOperations[operation];
|
|
16
|
+
const request = contract.request.parse(body);
|
|
17
|
+
const response = await transport(new URL(contract.path, host), {
|
|
18
|
+
method: "POST",
|
|
19
|
+
headers: {
|
|
20
|
+
authorization: `Bearer ${token}`,
|
|
21
|
+
"content-type": "application/json",
|
|
22
|
+
},
|
|
23
|
+
body: JSON.stringify(request),
|
|
24
|
+
});
|
|
25
|
+
if (!response.ok)
|
|
26
|
+
throw new ReportRequestError(response.status, await response.text());
|
|
27
|
+
// SAFETY: the selected operation owns both this response schema and the mapped response type.
|
|
28
|
+
return contract.response.parse(await response.json());
|
|
29
|
+
};
|
|
30
|
+
}
|
package/dist/sdk/client/rooms.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { createJsonApi } from "./json-api";
|
|
2
3
|
/** Every rooms endpoint answers a failure as `{ error, reason? }`. */
|
|
3
4
|
const failureSchema = z.object({
|
|
4
5
|
error: z.string().optional(),
|
|
@@ -22,32 +23,12 @@ export class RoomsRequestError extends Error {
|
|
|
22
23
|
* bearer and fail fast with a 401-shaped error when no session exists.
|
|
23
24
|
*/
|
|
24
25
|
export function createRoomsApi(host, getToken) {
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
throw new RoomsRequestError(401, "Sign in first");
|
|
32
|
-
}
|
|
33
|
-
if (options.body !== undefined)
|
|
34
|
-
headers["Content-Type"] = "application/json";
|
|
35
|
-
const res = await fetch(`${host}/api/rooms${path}`, {
|
|
36
|
-
method: options.method ?? "GET",
|
|
37
|
-
headers,
|
|
38
|
-
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
|
39
|
-
});
|
|
40
|
-
const payload = await res.json().catch(() => ({}));
|
|
41
|
-
if (!res.ok) {
|
|
42
|
-
const failure = failureSchema.safeParse(payload);
|
|
43
|
-
throw new RoomsRequestError(res.status, failure.success && failure.data.error
|
|
44
|
-
? failure.data.error
|
|
45
|
-
: `Rooms request failed (${res.status})`, failure.success ? failure.data.reason : undefined);
|
|
46
|
-
}
|
|
47
|
-
/* SAFETY: each caller names the response type of the rooms endpoint it just called; a
|
|
48
|
-
non-ok status has already thrown above with the server's own wording. */
|
|
49
|
-
return payload;
|
|
50
|
-
}
|
|
26
|
+
const { request } = createJsonApi(host, "/api/rooms", getToken, (status, message, reason) => new RoomsRequestError(status, message, reason), ({ status, payload }) => {
|
|
27
|
+
const parsed = failureSchema.safeParse(payload);
|
|
28
|
+
return new RoomsRequestError(status, parsed.success && parsed.data.error
|
|
29
|
+
? parsed.data.error
|
|
30
|
+
: `Rooms request failed (${status})`, parsed.success ? parsed.data.reason : undefined);
|
|
31
|
+
});
|
|
51
32
|
const slugPath = (slug) => `/${encodeURIComponent(slug)}`;
|
|
52
33
|
const act = (slug, action, body) => request(`${slugPath(slug)}/${action}`, {
|
|
53
34
|
method: "POST",
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { CatalogFieldRef, StudyCatalog, StudySearchHit, StudySubject } from "../types";
|
|
2
|
+
export declare function normalizeSearchText(text: string): string;
|
|
3
|
+
/** 4 exact, 3 prefix, 2 a later word's prefix, 1 substring, 0 no match.
|
|
4
|
+
* Both sides are normalized here so callers can pass raw labels. */
|
|
5
|
+
export declare function matchScore(query: string, candidate: string): number;
|
|
6
|
+
export declare function catalogFieldIndex(catalog: StudyCatalog): Map<string, CatalogFieldRef>;
|
|
7
|
+
/** Live subjects newest-used first; auth stamps `lastUsedAt` at creation, so
|
|
8
|
+
* a subject never studied sorts by when it was added. */
|
|
9
|
+
export declare function recentSubjects(subjects: readonly StudySubject[], limit?: number): StudySubject[];
|
|
10
|
+
/** Catalog fields the user already keeps under the field's own name are
|
|
11
|
+
* hidden from results — the subject hit covers them and picking the field
|
|
12
|
+
* again would only re-create the same subject. A renamed subject ("Math for
|
|
13
|
+
* my test" under Algebra) leaves the field visible for a second subject. */
|
|
14
|
+
export declare function fieldsCoveredBySubjects(subjects: readonly StudySubject[], index: Map<string, CatalogFieldRef>): Set<string>;
|
|
15
|
+
export declare function searchStudy(query: string, options: {
|
|
16
|
+
catalog: StudyCatalog;
|
|
17
|
+
subjects: readonly StudySubject[];
|
|
18
|
+
limit?: number;
|
|
19
|
+
}): StudySearchHit[];
|
|
20
|
+
export declare function hitLabel(hit: StudySearchHit): string;
|