@lotics/app-sdk 0.18.0 → 0.20.0
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/dist/src/hooks.d.ts +57 -10
- package/dist/src/hooks.js +129 -18
- package/dist/src/index.d.ts +4 -2
- package/dist/src/index.js +2 -1
- package/dist/src/rpc.d.ts +1 -1
- package/dist/src/rpc.js +10 -1
- package/dist/src/use_recents.d.ts +19 -0
- package/dist/src/use_recents.js +71 -0
- package/package.json +1 -1
package/dist/src/hooks.d.ts
CHANGED
|
@@ -1,18 +1,42 @@
|
|
|
1
1
|
import type { AppWorkflows, AppQueries } from "./types.js";
|
|
2
|
+
import type { ResolvedMember } from "./members.js";
|
|
2
3
|
interface QueryState<R> {
|
|
3
4
|
rows: R[];
|
|
4
5
|
loading: boolean;
|
|
5
6
|
error: string | null;
|
|
6
7
|
/**
|
|
7
|
-
* Re-run the
|
|
8
|
-
*
|
|
9
|
-
* latest state.
|
|
10
|
-
*
|
|
11
|
-
* The iframe SDK does not subscribe to realtime invalidation channels in
|
|
12
|
-
* v1; refetch is the explicit refresh path. Future SDK versions may add a
|
|
13
|
-
* `subscribe` RPC op for push-based invalidation.
|
|
8
|
+
* Re-run the query from the first page. Use after a known mutation point —
|
|
9
|
+
* a successful `useWorkflow(alias)()` call — to pull the latest state.
|
|
14
10
|
*/
|
|
15
11
|
refetch: () => void;
|
|
12
|
+
/**
|
|
13
|
+
* Fetch the next page and append it to `rows`. No-op when `pageSize` was not
|
|
14
|
+
* set or there are no more rows (`hasMore` is false). `loadingMore` is true
|
|
15
|
+
* while it runs.
|
|
16
|
+
*/
|
|
17
|
+
loadMore: () => void;
|
|
18
|
+
/** True when the last page came back full, so more rows may exist. */
|
|
19
|
+
hasMore: boolean;
|
|
20
|
+
/** True while a `loadMore` request is in flight. */
|
|
21
|
+
loadingMore: boolean;
|
|
22
|
+
}
|
|
23
|
+
/** Options for `useQuery`. */
|
|
24
|
+
export interface QueryOptions {
|
|
25
|
+
/**
|
|
26
|
+
* Page size. Omit to fetch all rows in one request (the server still caps
|
|
27
|
+
* the maximum). Set it to paginate: the first render loads one page, and
|
|
28
|
+
* `loadMore()` appends the next.
|
|
29
|
+
*/
|
|
30
|
+
pageSize?: number;
|
|
31
|
+
/**
|
|
32
|
+
* When `false`, the query does not run: `rows` stays empty, `loading` is
|
|
33
|
+
* false, and no request is sent. Flip it back to `true` to fetch. This is the
|
|
34
|
+
* primitive for search-as-you-type (skip until the user types) and for detail
|
|
35
|
+
* queries (skip until a row is selected) — a parameterized search filter
|
|
36
|
+
* matches everything on an empty term, so an always-on query would dump the
|
|
37
|
+
* whole table on first paint. Default `true`.
|
|
38
|
+
*/
|
|
39
|
+
enabled?: boolean;
|
|
16
40
|
}
|
|
17
41
|
/**
|
|
18
42
|
* Trigger a workflow by alias from the app's manifest.
|
|
@@ -48,7 +72,7 @@ export interface WorkflowResult {
|
|
|
48
72
|
message?: string;
|
|
49
73
|
files?: UploadedFile[];
|
|
50
74
|
}
|
|
51
|
-
type
|
|
75
|
+
type UseQueryArgs<K extends keyof AppQueries & string> = AppQueries[K] extends Record<string, never> ? [params?: Record<string, never>, opts?: QueryOptions] : [params: AppQueries[K], opts?: QueryOptions];
|
|
52
76
|
/**
|
|
53
77
|
* Read rows from a query the app's author declared in `lotics.queries`.
|
|
54
78
|
*
|
|
@@ -65,8 +89,8 @@ type UseQueryParams<K extends keyof AppQueries & string> = AppQueries[K] extends
|
|
|
65
89
|
* with the declared alias → param-type map, so an undeclared alias is a
|
|
66
90
|
* compile-time error and params are typed per the manifest.
|
|
67
91
|
*/
|
|
68
|
-
export declare function useQuery<K extends keyof AppQueries & string>(alias: K, ...
|
|
69
|
-
export declare function useQuery(alias: string, params?: Record<string, unknown
|
|
92
|
+
export declare function useQuery<K extends keyof AppQueries & string>(alias: K, ...args: UseQueryArgs<K>): QueryState<Record<string, unknown>>;
|
|
93
|
+
export declare function useQuery(alias: string, params?: Record<string, unknown>, opts?: QueryOptions): QueryState<Record<string, unknown>>;
|
|
70
94
|
/** A file the host has stored and resolved serving URLs for. */
|
|
71
95
|
export interface UploadedFile {
|
|
72
96
|
id: string;
|
|
@@ -100,4 +124,27 @@ interface FileUploadState {
|
|
|
100
124
|
* ```
|
|
101
125
|
*/
|
|
102
126
|
export declare function useFileUpload(): FileUploadState;
|
|
127
|
+
interface MembersState {
|
|
128
|
+
/** Members of the app's organization, for assign / member-picker UIs. */
|
|
129
|
+
members: ResolvedMember[];
|
|
130
|
+
loading: boolean;
|
|
131
|
+
error: string | null;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* List the members of the app's organization — the candidate set for an
|
|
135
|
+
* "assign to a member" picker. Resolves through the host (member-only; an
|
|
136
|
+
* anonymous public visitor gets an error). Names may be empty for members
|
|
137
|
+
* without a display name set — fall back to `email`.
|
|
138
|
+
*
|
|
139
|
+
* Gated: the app must DECLARE that it works with members — it needs a workflow
|
|
140
|
+
* whose manifest declares a `member`-typed input (i.e. it assigns members).
|
|
141
|
+
* Without one the host returns an error, so member access stays a declared,
|
|
142
|
+
* auditable surface rather than something any app can read ambiently.
|
|
143
|
+
*
|
|
144
|
+
* ```tsx
|
|
145
|
+
* const { members } = useMembers();
|
|
146
|
+
* // <Picker options={members.map((m) => ({ value: m.id, label: m.name || m.email || m.id }))} />
|
|
147
|
+
* ```
|
|
148
|
+
*/
|
|
149
|
+
export declare function useMembers(): MembersState;
|
|
103
150
|
export {};
|
package/dist/src/hooks.js
CHANGED
|
@@ -17,6 +17,9 @@ import { useCallback, useEffect, useState } from "react";
|
|
|
17
17
|
import { rpc } from "./rpc.js";
|
|
18
18
|
import { getMockRows } from "./mock.js";
|
|
19
19
|
import { captureAppEvent } from "./analytics.js";
|
|
20
|
+
// A tab return can fire both `focus` and `visibilitychange` back-to-back;
|
|
21
|
+
// coalesce them into a single revalidation.
|
|
22
|
+
const FOCUS_REVALIDATE_THROTTLE_MS = 2000;
|
|
20
23
|
export function useWorkflow(alias) {
|
|
21
24
|
return useCallback(async (inputs) => {
|
|
22
25
|
try {
|
|
@@ -30,55 +33,123 @@ export function useWorkflow(alias) {
|
|
|
30
33
|
}
|
|
31
34
|
}, [alias]);
|
|
32
35
|
}
|
|
33
|
-
export function useQuery(alias, params) {
|
|
36
|
+
export function useQuery(alias, params, opts) {
|
|
37
|
+
const pageSize = opts?.pageSize;
|
|
38
|
+
const enabled = opts?.enabled ?? true;
|
|
34
39
|
// Stringified params key — structurally-equal-but-new param objects don't
|
|
35
40
|
// re-fire the effect every render.
|
|
36
41
|
const paramsKey = JSON.stringify(params ?? {});
|
|
37
|
-
// Bumping this token re-fires the effect without changing
|
|
38
|
-
//
|
|
39
|
-
//
|
|
42
|
+
// Bumping this token re-fires the effect (first page) without changing
|
|
43
|
+
// alias/params. State transitions happen inside the effect so loading /
|
|
44
|
+
// error / rows flow consistently.
|
|
40
45
|
const [refetchToken, setRefetchToken] = useState(0);
|
|
41
46
|
// Initialize from the fixture when mock mode is on and the app registered
|
|
42
47
|
// rows for this alias — avoids a flash of `loading: true` on first paint.
|
|
43
|
-
// Computed lazily so subsequent renders don't re-read `window.location`.
|
|
44
48
|
const [state, setState] = useState(() => {
|
|
45
49
|
const mockRows = getMockRows(alias);
|
|
46
50
|
if (mockRows)
|
|
47
|
-
return { rows: mockRows, loading: false, error: null };
|
|
48
|
-
|
|
51
|
+
return { rows: mockRows, loading: false, error: null, hasMore: false };
|
|
52
|
+
// A disabled query starts idle (no loading flash), not pending.
|
|
53
|
+
return { rows: [], loading: enabled, error: null, hasMore: false };
|
|
49
54
|
});
|
|
55
|
+
const [loadingMore, setLoadingMore] = useState(false);
|
|
50
56
|
useEffect(() => {
|
|
51
|
-
// Re-check on every effect run so HMR updates to the fixture
|
|
52
|
-
//
|
|
53
|
-
// an entry we short-circuit the RPC — partial mocking still works because
|
|
54
|
-
// aliases without fixture entries fall through to the live path below.
|
|
57
|
+
// Re-check on every effect run so HMR updates to the fixture propagate
|
|
58
|
+
// without a hard reload. Fixture short-circuits the RPC.
|
|
55
59
|
const mockRows = getMockRows(alias);
|
|
56
60
|
if (mockRows) {
|
|
57
|
-
setState({ rows: mockRows, loading: false, error: null });
|
|
61
|
+
setState({ rows: mockRows, loading: false, error: null, hasMore: false });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (!enabled) {
|
|
65
|
+
setState({ rows: [], loading: false, error: null, hasMore: false });
|
|
58
66
|
return;
|
|
59
67
|
}
|
|
60
68
|
let cancelled = false;
|
|
61
|
-
setState((s) => ({
|
|
62
|
-
rpc("query", {
|
|
69
|
+
setState((s) => ({ ...s, loading: true, error: null }));
|
|
70
|
+
rpc("query", {
|
|
71
|
+
alias,
|
|
72
|
+
params: params ?? {},
|
|
73
|
+
limit: pageSize,
|
|
74
|
+
offset: 0,
|
|
75
|
+
})
|
|
63
76
|
.then((result) => {
|
|
64
77
|
if (cancelled)
|
|
65
78
|
return;
|
|
66
|
-
|
|
79
|
+
const rows = result.rows ?? [];
|
|
80
|
+
setState({
|
|
81
|
+
rows,
|
|
82
|
+
loading: false,
|
|
83
|
+
error: null,
|
|
84
|
+
hasMore: pageSize != null && rows.length === pageSize,
|
|
85
|
+
});
|
|
67
86
|
})
|
|
68
87
|
.catch((err) => {
|
|
69
88
|
if (cancelled)
|
|
70
89
|
return;
|
|
71
|
-
setState({ rows: [], loading: false, error: err.message });
|
|
90
|
+
setState({ rows: [], loading: false, error: err.message, hasMore: false });
|
|
72
91
|
});
|
|
73
92
|
return () => {
|
|
74
93
|
cancelled = true;
|
|
75
94
|
};
|
|
76
95
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
77
|
-
}, [alias, paramsKey, refetchToken]);
|
|
96
|
+
}, [alias, paramsKey, refetchToken, pageSize, enabled]);
|
|
78
97
|
const refetch = useCallback(() => {
|
|
79
98
|
setRefetchToken((n) => n + 1);
|
|
80
99
|
}, []);
|
|
81
|
-
|
|
100
|
+
// Revalidate on focus / reconnect. An app can't hold a realtime socket, so
|
|
101
|
+
// freshness comes from re-running the query (first page) when the user
|
|
102
|
+
// returns to it or the network comes back — plus the explicit `refetch()`
|
|
103
|
+
// an app calls after a `useWorkflow()` mutation. Throttled so a tab return
|
|
104
|
+
// that fires both `focus` and `visibilitychange` only refetches once.
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
let last = 0;
|
|
107
|
+
const revalidate = () => {
|
|
108
|
+
if (document.visibilityState === "hidden")
|
|
109
|
+
return;
|
|
110
|
+
const now = performance.now();
|
|
111
|
+
if (now - last < FOCUS_REVALIDATE_THROTTLE_MS)
|
|
112
|
+
return;
|
|
113
|
+
last = now;
|
|
114
|
+
setRefetchToken((n) => n + 1);
|
|
115
|
+
};
|
|
116
|
+
window.addEventListener("focus", revalidate);
|
|
117
|
+
window.addEventListener("online", revalidate);
|
|
118
|
+
document.addEventListener("visibilitychange", revalidate);
|
|
119
|
+
return () => {
|
|
120
|
+
window.removeEventListener("focus", revalidate);
|
|
121
|
+
window.removeEventListener("online", revalidate);
|
|
122
|
+
document.removeEventListener("visibilitychange", revalidate);
|
|
123
|
+
};
|
|
124
|
+
}, []);
|
|
125
|
+
const loadMore = useCallback(() => {
|
|
126
|
+
if (pageSize == null || loadingMore || state.loading || !state.hasMore)
|
|
127
|
+
return;
|
|
128
|
+
setLoadingMore(true);
|
|
129
|
+
rpc("query", {
|
|
130
|
+
alias,
|
|
131
|
+
params: params ?? {},
|
|
132
|
+
limit: pageSize,
|
|
133
|
+
offset: state.rows.length,
|
|
134
|
+
})
|
|
135
|
+
.then((result) => {
|
|
136
|
+
const page = result.rows ?? [];
|
|
137
|
+
setState((cur) => ({
|
|
138
|
+
...cur,
|
|
139
|
+
rows: [...cur.rows, ...page],
|
|
140
|
+
hasMore: page.length === pageSize,
|
|
141
|
+
}));
|
|
142
|
+
})
|
|
143
|
+
.catch((err) => {
|
|
144
|
+
// Keep the rows we have — a failed page-append shouldn't blank the
|
|
145
|
+
// list — but surface the error instead of swallowing it. `hasMore`
|
|
146
|
+
// stays true, so the app can retry `loadMore()`.
|
|
147
|
+
setState((cur) => ({ ...cur, error: err.message }));
|
|
148
|
+
})
|
|
149
|
+
.finally(() => setLoadingMore(false));
|
|
150
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
151
|
+
}, [alias, paramsKey, pageSize, loadingMore, state.loading, state.hasMore, state.rows.length]);
|
|
152
|
+
return { ...state, refetch, loadMore, loadingMore };
|
|
82
153
|
}
|
|
83
154
|
/**
|
|
84
155
|
* Upload files from an app. The bytes are stored via a presigned
|
|
@@ -114,3 +185,43 @@ export function useFileUpload() {
|
|
|
114
185
|
}, []);
|
|
115
186
|
return { upload, uploading: inFlight > 0, error };
|
|
116
187
|
}
|
|
188
|
+
/**
|
|
189
|
+
* List the members of the app's organization — the candidate set for an
|
|
190
|
+
* "assign to a member" picker. Resolves through the host (member-only; an
|
|
191
|
+
* anonymous public visitor gets an error). Names may be empty for members
|
|
192
|
+
* without a display name set — fall back to `email`.
|
|
193
|
+
*
|
|
194
|
+
* Gated: the app must DECLARE that it works with members — it needs a workflow
|
|
195
|
+
* whose manifest declares a `member`-typed input (i.e. it assigns members).
|
|
196
|
+
* Without one the host returns an error, so member access stays a declared,
|
|
197
|
+
* auditable surface rather than something any app can read ambiently.
|
|
198
|
+
*
|
|
199
|
+
* ```tsx
|
|
200
|
+
* const { members } = useMembers();
|
|
201
|
+
* // <Picker options={members.map((m) => ({ value: m.id, label: m.name || m.email || m.id }))} />
|
|
202
|
+
* ```
|
|
203
|
+
*/
|
|
204
|
+
export function useMembers() {
|
|
205
|
+
const [state, setState] = useState({
|
|
206
|
+
members: [],
|
|
207
|
+
loading: true,
|
|
208
|
+
error: null,
|
|
209
|
+
});
|
|
210
|
+
useEffect(() => {
|
|
211
|
+
let cancelled = false;
|
|
212
|
+
setState((s) => ({ ...s, loading: true, error: null }));
|
|
213
|
+
rpc("members", {})
|
|
214
|
+
.then((r) => {
|
|
215
|
+
if (!cancelled)
|
|
216
|
+
setState({ members: r.members ?? [], loading: false, error: null });
|
|
217
|
+
})
|
|
218
|
+
.catch((err) => {
|
|
219
|
+
if (!cancelled)
|
|
220
|
+
setState({ members: [], loading: false, error: err.message });
|
|
221
|
+
});
|
|
222
|
+
return () => {
|
|
223
|
+
cancelled = true;
|
|
224
|
+
};
|
|
225
|
+
}, []);
|
|
226
|
+
return state;
|
|
227
|
+
}
|
package/dist/src/index.d.ts
CHANGED
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
*/
|
|
17
17
|
export { mount } from "./mount.js";
|
|
18
18
|
export type { MountOptions } from "./mount.js";
|
|
19
|
-
export { useWorkflow, useQuery, useFileUpload } from "./hooks.js";
|
|
20
|
-
export type { UploadedFile, WorkflowResult } from "./hooks.js";
|
|
19
|
+
export { useWorkflow, useQuery, useFileUpload, useMembers } from "./hooks.js";
|
|
20
|
+
export type { UploadedFile, QueryOptions, WorkflowResult } from "./hooks.js";
|
|
21
21
|
export { rpc } from "./rpc.js";
|
|
22
22
|
export type { RpcOp } from "./rpc.js";
|
|
23
23
|
export { openExternal } from "./open_external.js";
|
|
@@ -31,3 +31,5 @@ export { row, readLinks } from "./row.js";
|
|
|
31
31
|
export type { ResolvedLink } from "./row.js";
|
|
32
32
|
export { useOptimistic } from "./use_optimistic.js";
|
|
33
33
|
export type { OptimisticApi } from "./use_optimistic.js";
|
|
34
|
+
export { useRecents } from "./use_recents.js";
|
|
35
|
+
export type { RecentsApi, RecentsOptions } from "./use_recents.js";
|
package/dist/src/index.js
CHANGED
|
@@ -15,10 +15,11 @@
|
|
|
15
15
|
* not raw HTML/CSS. See `docs/apps.md` → "Styling & components".
|
|
16
16
|
*/
|
|
17
17
|
export { mount } from "./mount.js";
|
|
18
|
-
export { useWorkflow, useQuery, useFileUpload } from "./hooks.js";
|
|
18
|
+
export { useWorkflow, useQuery, useFileUpload, useMembers } from "./hooks.js";
|
|
19
19
|
export { rpc } from "./rpc.js";
|
|
20
20
|
export { openExternal } from "./open_external.js";
|
|
21
21
|
export { readMembers } from "./members.js";
|
|
22
22
|
export { readSelect } from "./select.js";
|
|
23
23
|
export { row, readLinks } from "./row.js";
|
|
24
24
|
export { useOptimistic } from "./use_optimistic.js";
|
|
25
|
+
export { useRecents } from "./use_recents.js";
|
package/dist/src/rpc.d.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* app → host: { id, op, payload }
|
|
19
19
|
* host → app: { id, type: "result", data } | { id, type: "error", message }
|
|
20
20
|
*/
|
|
21
|
-
export type RpcOp = "query" | "workflow" | "upload" | "context" | "openExternal";
|
|
21
|
+
export type RpcOp = "query" | "workflow" | "upload" | "members" | "context" | "openExternal";
|
|
22
22
|
/**
|
|
23
23
|
* The app's identity, resolved once at startup to tag PostHog events.
|
|
24
24
|
* Assembled by whichever transport is active:
|
package/dist/src/rpc.js
CHANGED
|
@@ -217,12 +217,21 @@ function rpcStandalone(op, payload) {
|
|
|
217
217
|
return standaloneWorkflow(payload);
|
|
218
218
|
case "upload":
|
|
219
219
|
return standaloneUpload(payload.file);
|
|
220
|
+
case "members":
|
|
221
|
+
return standaloneMembers();
|
|
220
222
|
case "context":
|
|
221
223
|
return standaloneContext();
|
|
222
224
|
case "openExternal":
|
|
223
225
|
return standaloneOpenExternal(payload);
|
|
224
226
|
}
|
|
225
227
|
}
|
|
228
|
+
async function standaloneMembers() {
|
|
229
|
+
const { app_id } = await boot();
|
|
230
|
+
const r = (await apiCall("GET", `/v1/apps/${app_id}/members`, undefined, {
|
|
231
|
+
appId: app_id,
|
|
232
|
+
}));
|
|
233
|
+
return { members: r.members ?? [] };
|
|
234
|
+
}
|
|
226
235
|
/**
|
|
227
236
|
* Open an external URL in a new tab, scheme-validated. In standalone mode the
|
|
228
237
|
* app is a normal top-level page (`<slug>.lotics.app`), so `window.open` is not
|
|
@@ -257,7 +266,7 @@ async function standaloneContext() {
|
|
|
257
266
|
}
|
|
258
267
|
async function standaloneQuery(p) {
|
|
259
268
|
const { app_id } = await boot();
|
|
260
|
-
const r = (await apiCall("POST", `/v1/apps/${app_id}/query`, { alias: p.alias, params: p.params }, { appId: app_id }));
|
|
269
|
+
const r = (await apiCall("POST", `/v1/apps/${app_id}/query`, { alias: p.alias, params: p.params, limit: p.limit, offset: p.offset }, { appId: app_id }));
|
|
261
270
|
return { rows: r.rows ?? [] };
|
|
262
271
|
}
|
|
263
272
|
async function standaloneWorkflow(p) {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface RecentsApi<T> {
|
|
2
|
+
/** Remembered items, most-recent first (deduped by `keyOf`, capped at `max`). */
|
|
3
|
+
recents: T[];
|
|
4
|
+
/** Record `item` as most-recent: moves an existing match to the front, caps,
|
|
5
|
+
* and persists. Call this on select. */
|
|
6
|
+
remember: (item: T) => void;
|
|
7
|
+
/** Drop one remembered item (matched by `keyOf`). */
|
|
8
|
+
forget: (item: T) => void;
|
|
9
|
+
/** Clear the whole list. */
|
|
10
|
+
clear: () => void;
|
|
11
|
+
}
|
|
12
|
+
export interface RecentsOptions<T> {
|
|
13
|
+
/** Stable identity per item — used to dedup and to match `forget`. Default:
|
|
14
|
+
* `JSON.stringify`. Pass the item's id for objects you'll re-create. */
|
|
15
|
+
keyOf?: (item: T) => string;
|
|
16
|
+
/** Maximum items kept. Default 5. */
|
|
17
|
+
max?: number;
|
|
18
|
+
}
|
|
19
|
+
export declare function useRecents<T>(key: string, options?: RecentsOptions<T>): RecentsApi<T>;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remember a small, most-recent-first list of items across sessions — the
|
|
3
|
+
* "recently used" affordance a search box shows when focused but empty.
|
|
4
|
+
*
|
|
5
|
+
* Persistence is `localStorage`, namespaced per `key`, so two comboboxes on one
|
|
6
|
+
* page keep separate lists and a deployed app's recents survive reloads. The
|
|
7
|
+
* SDK is the right home (not `@lotics/ui`): `localStorage` is web-only, and
|
|
8
|
+
* `@lotics/ui` also builds for native, where it doesn't exist. The list is
|
|
9
|
+
* always state-backed, so the hook keeps working in-memory if storage is
|
|
10
|
+
* unavailable (private mode / quota) — recents is an enhancement, never a
|
|
11
|
+
* reason to crash the app.
|
|
12
|
+
*/
|
|
13
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
14
|
+
const PREFIX = "lotics.recents.";
|
|
15
|
+
function read(storageKey) {
|
|
16
|
+
try {
|
|
17
|
+
const raw = localStorage.getItem(storageKey);
|
|
18
|
+
if (raw == null)
|
|
19
|
+
return [];
|
|
20
|
+
const parsed = JSON.parse(raw);
|
|
21
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// Storage unavailable or corrupt JSON: start empty, keep working in-memory.
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function write(storageKey, items) {
|
|
29
|
+
try {
|
|
30
|
+
localStorage.setItem(storageKey, JSON.stringify(items));
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// Storage unavailable (private mode / quota): the in-memory list still
|
|
34
|
+
// updates; only cross-reload persistence is lost.
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function useRecents(key, options = {}) {
|
|
38
|
+
const storageKey = PREFIX + key;
|
|
39
|
+
// Config is captured in refs so the returned callbacks stay referentially
|
|
40
|
+
// stable across renders even when the caller passes an inline `keyOf`.
|
|
41
|
+
const keyOfRef = useRef(options.keyOf ?? ((item) => JSON.stringify(item)));
|
|
42
|
+
keyOfRef.current = options.keyOf ?? ((item) => JSON.stringify(item));
|
|
43
|
+
const maxRef = useRef(options.max ?? 5);
|
|
44
|
+
maxRef.current = options.max ?? 5;
|
|
45
|
+
const [recents, setRecents] = useState(() => read(storageKey).slice(0, maxRef.current));
|
|
46
|
+
// Re-hydrate when the namespace changes (a different list on the same screen).
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
setRecents(read(storageKey).slice(0, maxRef.current));
|
|
49
|
+
}, [storageKey]);
|
|
50
|
+
const remember = useCallback((item) => {
|
|
51
|
+
setRecents((prev) => {
|
|
52
|
+
const k = keyOfRef.current(item);
|
|
53
|
+
const next = [item, ...prev.filter((p) => keyOfRef.current(p) !== k)].slice(0, maxRef.current);
|
|
54
|
+
write(storageKey, next);
|
|
55
|
+
return next;
|
|
56
|
+
});
|
|
57
|
+
}, [storageKey]);
|
|
58
|
+
const forget = useCallback((item) => {
|
|
59
|
+
setRecents((prev) => {
|
|
60
|
+
const k = keyOfRef.current(item);
|
|
61
|
+
const next = prev.filter((p) => keyOfRef.current(p) !== k);
|
|
62
|
+
write(storageKey, next);
|
|
63
|
+
return next;
|
|
64
|
+
});
|
|
65
|
+
}, [storageKey]);
|
|
66
|
+
const clear = useCallback(() => {
|
|
67
|
+
write(storageKey, []);
|
|
68
|
+
setRecents([]);
|
|
69
|
+
}, [storageKey]);
|
|
70
|
+
return { recents, remember, forget, clear };
|
|
71
|
+
}
|