@aglyn/tenant-feature-instance 1.0.0-beta.143
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 +3 -0
- package/jest.config.d.ts +36 -0
- package/package.json +45 -0
- package/src/index.d.ts +17 -0
- package/src/index.js +18 -0
- package/src/index.js.map +1 -0
- package/src/lib/components/duplicate-resource-dialog.d.ts +77 -0
- package/src/lib/components/duplicate-resource-dialog.js +217 -0
- package/src/lib/components/duplicate-resource-dialog.js.map +1 -0
- package/src/lib/constants/firebase-config.d.ts +68 -0
- package/src/lib/constants/firebase-config.js +155 -0
- package/src/lib/constants/firebase-config.js.map +1 -0
- package/src/lib/constants/firebase-emulator-hosts.d.ts +60 -0
- package/src/lib/constants/firebase-emulator-hosts.js +81 -0
- package/src/lib/constants/firebase-emulator-hosts.js.map +1 -0
- package/src/lib/constants/mock-data.d.ts +24 -0
- package/src/lib/constants/mock-data.js +49 -0
- package/src/lib/constants/mock-data.js.map +1 -0
- package/src/lib/constants/site-paths.d.ts +47 -0
- package/src/lib/constants/site-paths.js +66 -0
- package/src/lib/constants/site-paths.js.map +1 -0
- package/src/lib/hooks/firebase/auth-persistence.d.ts +74 -0
- package/src/lib/hooks/firebase/auth-persistence.js +117 -0
- package/src/lib/hooks/firebase/auth-persistence.js.map +1 -0
- package/src/lib/hooks/firebase/firebase-services.d.ts +172 -0
- package/src/lib/hooks/firebase/firebase-services.js +522 -0
- package/src/lib/hooks/firebase/firebase-services.js.map +1 -0
- package/src/lib/hooks/firebase/firestore-cache.d.ts +125 -0
- package/src/lib/hooks/firebase/firestore-cache.js +152 -0
- package/src/lib/hooks/firebase/firestore-cache.js.map +1 -0
- package/src/lib/hooks/firebase/firestore-shared-client-state.d.ts +169 -0
- package/src/lib/hooks/firebase/firestore-shared-client-state.js +316 -0
- package/src/lib/hooks/firebase/firestore-shared-client-state.js.map +1 -0
- package/src/lib/hooks/firestore-denial-reporter.d.ts +192 -0
- package/src/lib/hooks/firestore-denial-reporter.js +293 -0
- package/src/lib/hooks/firestore-denial-reporter.js.map +1 -0
- package/src/lib/hooks/helpers/besigner-nodes-converter.d.ts +62 -0
- package/src/lib/hooks/helpers/besigner-nodes-converter.js +91 -0
- package/src/lib/hooks/helpers/besigner-nodes-converter.js.map +1 -0
- package/src/lib/hooks/helpers/guarded-nodes-save.d.ts +80 -0
- package/src/lib/hooks/helpers/guarded-nodes-save.js +89 -0
- package/src/lib/hooks/helpers/guarded-nodes-save.js.map +1 -0
- package/src/lib/hooks/helpers/guarded-seed-write.d.ts +125 -0
- package/src/lib/hooks/helpers/guarded-seed-write.js +193 -0
- package/src/lib/hooks/helpers/guarded-seed-write.js.map +1 -0
- package/src/lib/hooks/helpers/listen-options.d.ts +91 -0
- package/src/lib/hooks/helpers/listen-options.js +92 -0
- package/src/lib/hooks/helpers/listen-options.js.map +1 -0
- package/src/lib/hooks/helpers/use-doc.d.ts +38 -0
- package/src/lib/hooks/helpers/use-doc.js +213 -0
- package/src/lib/hooks/helpers/use-doc.js.map +1 -0
- package/src/lib/hooks/helpers/use-modify-doc-callback.d.ts +28 -0
- package/src/lib/hooks/helpers/use-modify-doc-callback.js +110 -0
- package/src/lib/hooks/helpers/use-modify-doc-callback.js.map +1 -0
- package/src/lib/hooks/host-collection-queries.d.ts +125 -0
- package/src/lib/hooks/host-collection-queries.js +126 -0
- package/src/lib/hooks/host-collection-queries.js.map +1 -0
- package/src/lib/hooks/list-filter-constraints.d.ts +64 -0
- package/src/lib/hooks/list-filter-constraints.js +288 -0
- package/src/lib/hooks/list-filter-constraints.js.map +1 -0
- package/src/lib/hooks/sorted-collection-window.d.ts +207 -0
- package/src/lib/hooks/sorted-collection-window.js +150 -0
- package/src/lib/hooks/sorted-collection-window.js.map +1 -0
- package/src/lib/hooks/use-component-version.d.ts +43 -0
- package/src/lib/hooks/use-component-version.js +73 -0
- package/src/lib/hooks/use-component-version.js.map +1 -0
- package/src/lib/hooks/use-component.d.ts +39 -0
- package/src/lib/hooks/use-component.js +55 -0
- package/src/lib/hooks/use-component.js.map +1 -0
- package/src/lib/hooks/use-console-host-route.d.ts +24 -0
- package/src/lib/hooks/use-console-host-route.js +77 -0
- package/src/lib/hooks/use-console-host-route.js.map +1 -0
- package/src/lib/hooks/use-duplicate-resource-api.d.ts +43 -0
- package/src/lib/hooks/use-duplicate-resource-api.js +61 -0
- package/src/lib/hooks/use-duplicate-resource-api.js.map +1 -0
- package/src/lib/hooks/use-firestore-collection.d.ts +62 -0
- package/src/lib/hooks/use-firestore-collection.js +307 -0
- package/src/lib/hooks/use-firestore-collection.js.map +1 -0
- package/src/lib/hooks/use-firestore-doc.d.ts +42 -0
- package/src/lib/hooks/use-firestore-doc.js +192 -0
- package/src/lib/hooks/use-firestore-doc.js.map +1 -0
- package/src/lib/hooks/use-form-version.d.ts +44 -0
- package/src/lib/hooks/use-form-version.js +73 -0
- package/src/lib/hooks/use-form-version.js.map +1 -0
- package/src/lib/hooks/use-host-activity-logger.d.ts +35 -0
- package/src/lib/hooks/use-host-activity-logger.js +96 -0
- package/src/lib/hooks/use-host-activity-logger.js.map +1 -0
- package/src/lib/hooks/use-host-campaigns.d.ts +53 -0
- package/src/lib/hooks/use-host-campaigns.js +93 -0
- package/src/lib/hooks/use-host-campaigns.js.map +1 -0
- package/src/lib/hooks/use-host-org-id.d.ts +96 -0
- package/src/lib/hooks/use-host-org-id.js +154 -0
- package/src/lib/hooks/use-host-org-id.js.map +1 -0
- package/src/lib/hooks/use-host-resource-api.d.ts +43 -0
- package/src/lib/hooks/use-host-resource-api.js +60 -0
- package/src/lib/hooks/use-host-resource-api.js.map +1 -0
- package/src/lib/hooks/use-host-template.d.ts +44 -0
- package/src/lib/hooks/use-host-template.js +82 -0
- package/src/lib/hooks/use-host-template.js.map +1 -0
- package/src/lib/hooks/use-host-version-api.d.ts +59 -0
- package/src/lib/hooks/use-host-version-api.js +69 -0
- package/src/lib/hooks/use-host-version-api.js.map +1 -0
- package/src/lib/hooks/use-host.d.ts +101 -0
- package/src/lib/hooks/use-host.js +45 -0
- package/src/lib/hooks/use-host.js.map +1 -0
- package/src/lib/hooks/use-layout-version.d.ts +33 -0
- package/src/lib/hooks/use-layout-version.js +64 -0
- package/src/lib/hooks/use-layout-version.js.map +1 -0
- package/src/lib/hooks/use-layout.d.ts +41 -0
- package/src/lib/hooks/use-layout.js +49 -0
- package/src/lib/hooks/use-layout.js.map +1 -0
- package/src/lib/hooks/use-live-artifact-count.d.ts +50 -0
- package/src/lib/hooks/use-live-artifact-count.js +102 -0
- package/src/lib/hooks/use-live-artifact-count.js.map +1 -0
- package/src/lib/hooks/use-org-member-options.d.ts +57 -0
- package/src/lib/hooks/use-org-member-options.js +112 -0
- package/src/lib/hooks/use-org-member-options.js.map +1 -0
- package/src/lib/hooks/use-org-plan.d.ts +46 -0
- package/src/lib/hooks/use-org-plan.js +86 -0
- package/src/lib/hooks/use-org-plan.js.map +1 -0
- package/src/lib/hooks/use-paged-collection.d.ts +95 -0
- package/src/lib/hooks/use-paged-collection.js +130 -0
- package/src/lib/hooks/use-paged-collection.js.map +1 -0
- package/src/lib/hooks/use-plugin-config.d.ts +38 -0
- package/src/lib/hooks/use-plugin-config.js +120 -0
- package/src/lib/hooks/use-plugin-config.js.map +1 -0
- package/src/lib/hooks/use-scope-tokens.d.ts +28 -0
- package/src/lib/hooks/use-scope-tokens.js +100 -0
- package/src/lib/hooks/use-scope-tokens.js.map +1 -0
- package/src/lib/hooks/use-screen-version.d.ts +33 -0
- package/src/lib/hooks/use-screen-version.js +65 -0
- package/src/lib/hooks/use-screen-version.js.map +1 -0
- package/src/lib/hooks/use-screen.d.ts +68 -0
- package/src/lib/hooks/use-screen.js +50 -0
- package/src/lib/hooks/use-screen.js.map +1 -0
- package/src/lib/hooks/use-sending-identity-api.d.ts +197 -0
- package/src/lib/hooks/use-sending-identity-api.js +95 -0
- package/src/lib/hooks/use-sending-identity-api.js.map +1 -0
- package/src/lib/hooks/use-sorted-paged-collection.d.ts +48 -0
- package/src/lib/hooks/use-sorted-paged-collection.js +213 -0
- package/src/lib/hooks/use-sorted-paged-collection.js.map +1 -0
- package/src/lib/hooks/use-switcher-collection.d.ts +105 -0
- package/src/lib/hooks/use-switcher-collection.js +275 -0
- package/src/lib/hooks/use-switcher-collection.js.map +1 -0
- package/src/lib/hooks/use-user-name.d.ts +51 -0
- package/src/lib/hooks/use-user-name.js +90 -0
- package/src/lib/hooks/use-user-name.js.map +1 -0
- package/src/lib/hooks/use-user-photo.d.ts +50 -0
- package/src/lib/hooks/use-user-photo.js +87 -0
- package/src/lib/hooks/use-user-photo.js.map +1 -0
- package/src/lib/tenant.d.ts +61 -0
- package/src/lib/tenant.js +62 -0
- package/src/lib/tenant.js.map +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/use-sorted-paged-collection.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport type { DocumentData, Query } from 'firebase/firestore'\nimport {\n useCallback,\n useEffect,\n useRef,\n useState,\n type DependencyList,\n} from 'react'\nimport { TABLE_PAGE_SIZE_DEFAULT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport {\n planKeyedSegment,\n planSortedWindow,\n sortedKeyedQuery,\n sortedScanLimit,\n sortedUnkeyedQuery,\n sortFieldIsTotal,\n type CollectionSort,\n} from './sorted-collection-window'\nimport {\n useFirestoreCollection,\n type FirestoreCollectionStatus,\n type UseFirestoreCollectionOptions,\n} from './use-firestore-collection'\nimport type { UsePagedCollectionResult } from './use-paged-collection'\n\nexport interface UseSortedPagedCollectionOptions\n extends UseFirestoreCollectionOptions {\n /**\n * Required: the keyed and unkeyed segments are two listeners, and a\n * document moving between them is recognized by its id.\n */\n idField: string\n /** Rows per page. Defaults to the console-wide smallest option. */\n pageSize?: number\n /**\n * The fields `buildBase` pins by EQUALITY. A sort on one of them needs no\n * unkeyed segment — see `sortFieldIsTotal`.\n */\n equalityFields?: readonly string[]\n}\n\n/** `Object.is` over two dependency lists, the comparison React itself uses. */\nconst sameDeps = (a: DependencyList, b: DependencyList) =>\n a.length === b.length && a.every((value, index) => Object.is(value, b[index]))\n\n/**\n * The request a read's CURRENT rows were delivered for.\n *\n * `useFirestoreCollection` re-subscribes in an effect, so the render in which\n * its inputs change still returns the previous subscription's rows — rows for\n * another limit, sort or subject. Planning on them would judge a window by a\n * read that was never made for it: page two's window measured against page\n * one's eleven rows looks like a walk that ran out.\n *\n * So rows are labeled with the request that was current when they ARRIVED,\n * which is the render their array first appears in, and a render whose request\n * has since moved on treats them as unread. Held as state and adjusted during\n * render — the pattern React documents for state that follows a prop — so no\n * render ever pairs a request with rows read for another.\n */\nfunction useCurrentRows(rows: unknown, request: DependencyList): boolean {\n const [delivered, setDelivered] = useState<{\n rows: unknown\n request: DependencyList\n }>(() => ({ rows, request }))\n if (delivered.rows !== rows) {\n setDelivered({ rows, request })\n return true\n }\n return sameDeps(delivered.request, request)\n}\n\n/**\n * A paged window over a live collection SORTED BY A FIELD, which still drops\n * no document that lacks the field (AGL-2853).\n *\n * The same contract as `usePagedCollection` — the window is the query, one\n * page plus a probe row, `hasMore` a fact — over the two-segment walk in\n * `sorted-collection-window.ts`: the documents that carry the sort field in\n * its order, then the ones that do not, in name order. See that module for why\n * both segments are needed and what the second one costs.\n *\n * `buildBase` returns the collection with any EQUALITY predicates and nothing\n * else; the ordering and the limits are this hook's. `deps` identify the\n * subject as they do for `useFirestoreCollection`. A new subject, sort or\n * filter starts again from page one.\n *\n * ```ts\n * useSortedPagedCollection<Entry>(\n * () => query(\n * collection(firestore, 'hosts', hostId, 'collections', collectionId, 'entries'),\n * where('status', '==', status),\n * ),\n * { field: 'publishedAt', direction: 'desc' },\n * [firestore, hostId, collectionId, status],\n * { idField: '$id', equalityFields: ['status'] },\n * )\n * ```\n */\nexport function useSortedPagedCollection<T = DocumentData>(\n buildBase: () => Query<DocumentData> | null | undefined,\n sort: CollectionSort,\n deps: DependencyList,\n options: UseSortedPagedCollectionOptions,\n): UsePagedCollectionResult<T> {\n const {\n pageSize: initialPageSize = TABLE_PAGE_SIZE_DEFAULT,\n equalityFields = [],\n ...collectionOptions\n } = options\n const idField = options.idField\n const [pageSize, setPageSizeState] = useState(initialPageSize)\n const buildBaseRef = useRef(buildBase)\n buildBaseRef.current = buildBase\n\n const equalityKey = equalityFields.join('\\n')\n const subject: DependencyList = [\n ...deps,\n sort.field,\n sort.direction,\n equalityKey,\n ]\n\n /*\n * The reader's position belongs to ONE subject. It resets in the render the\n * subject changes in, not in an effect after it: an effect would let that\n * render subscribe the new sort at the old page's limit, and a sort change\n * on page four would bill forty-one documents to show ten.\n */\n const [cursor, setCursor] = useState(() => ({\n subject,\n page: 0,\n widenedTo: 0,\n }))\n const sameSubject = sameDeps(cursor.subject, subject)\n const position = sameSubject ? cursor : { subject, page: 0, widenedTo: 0 }\n if (!sameSubject) setCursor(position)\n const { page } = position\n\n const keyedIsTotal = sortFieldIsTotal(sort, equalityFields)\n const { keyedLimit } = planKeyedSegment({\n page,\n pageSize,\n keyed: undefined,\n keyedSettled: false,\n keyedIsTotal,\n })\n\n const keyedRequest: DependencyList = [...subject, keyedLimit]\n const keyedRead = useFirestoreCollection<T>(\n () => {\n const base = buildBaseRef.current()\n return base\n ? sortedKeyedQuery(base, sort, keyedLimit, equalityFields)\n : null\n },\n keyedRequest,\n collectionOptions,\n )\n const keyedCurrent = useCurrentRows(keyedRead.data, keyedRequest)\n const keyed =\n keyedCurrent && keyedRead.status !== 'loading' ? keyedRead.data : undefined\n const keyedSettled =\n keyedCurrent && keyedRead.status === 'success' && !keyedRead.fromCache\n\n const keyedPlan = planKeyedSegment({\n page,\n pageSize,\n keyed,\n keyedSettled,\n keyedIsTotal,\n })\n const scanLimit = sortedScanLimit(keyedPlan, position.widenedTo)\n\n const scanRequest: DependencyList = [...subject, scanLimit]\n const scanRead = useFirestoreCollection<T>(\n () => {\n if (scanLimit <= 0) return null\n const base = buildBaseRef.current()\n return base ? sortedUnkeyedQuery(base, scanLimit) : null\n },\n scanRequest,\n collectionOptions,\n )\n const scanCurrent = useCurrentRows(scanRead.data, scanRequest)\n const scanOpen = scanLimit > 0\n\n const sorted = planSortedWindow<T>({\n page,\n pageSize,\n sort,\n keyedIsTotal,\n keyed,\n keyedSettled,\n scan:\n scanOpen && scanCurrent && scanRead.status !== 'loading'\n ? scanRead.data\n : undefined,\n scanSettled:\n scanOpen &&\n scanCurrent &&\n scanRead.status === 'success' &&\n !scanRead.fromCache,\n scanLimit,\n idOf: (row) => String((row as Record<string, unknown>)[idField]),\n })\n\n /*\n * Widening is remembered for the subject, so paging back to where the scan\n * is not needed and forward again re-opens it at the width it reached\n * instead of doubling its way back up.\n */\n const widenTo = sorted.scanLimit\n useEffect(() => {\n if (widenTo <= position.widenedTo) return\n setCursor((previous) =>\n sameDeps(previous.subject, position.subject) &&\n widenTo > previous.widenedTo\n ? { ...previous, widenedTo: widenTo }\n : previous,\n )\n }, [widenTo, position.widenedTo, position.subject])\n\n const setPage = useCallback((next: number) => {\n setCursor((previous) => ({ ...previous, page: next }))\n }, [])\n const setPageSize = useCallback((next: number) => {\n setPageSizeState(next)\n // Page four of a ten-row list does not exist once the reader asks for\n // fifty at a time, and an out-of-range page renders as an empty list.\n setCursor((previous) => ({ ...previous, page: 0 }))\n }, [])\n\n /*\n * A short window is a claim that the list ENDS here, so it is `loading`\n * until the server has confirmed both segments. A window holding its probe\n * row claims only that more exists, which a cached read can say.\n */\n const errored =\n keyedRead.status === 'error' || (scanOpen && scanRead.status === 'error')\n const status: FirestoreCollectionStatus = errored\n ? 'error'\n : keyed === undefined\n ? 'loading'\n : sorted.hasMore || sorted.settled\n ? 'success'\n : 'loading'\n\n return {\n data: sorted.ordered,\n rows: sorted.rows,\n hasMore: sorted.hasMore,\n page,\n setPage,\n pageSize,\n setPageSize,\n status,\n error: keyedRead.error ?? (scanOpen ? scanRead.error : undefined),\n fromCache: keyedRead.fromCache || (scanOpen && scanRead.fromCache),\n serverDenied:\n keyedRead.serverDenied || (scanOpen && scanRead.serverDenied),\n }\n}\n\nexport default useSortedPagedCollection\n"],"names":["useCallback","useEffect","useRef","useState","TABLE_PAGE_SIZE_DEFAULT","planKeyedSegment","planSortedWindow","sortedKeyedQuery","sortedScanLimit","sortedUnkeyedQuery","sortFieldIsTotal","useFirestoreCollection","sameDeps","a","b","length","every","value","index","Object","is","useCurrentRows","rows","request","delivered","setDelivered","useSortedPagedCollection","buildBase","sort","deps","options","keyedRead","pageSize","initialPageSize","equalityFields","collectionOptions","idField","setPageSizeState","buildBaseRef","current","equalityKey","join","subject","field","direction","cursor","setCursor","page","widenedTo","sameSubject","position","keyedIsTotal","keyedLimit","keyed","undefined","keyedSettled","keyedRequest","base","keyedCurrent","data","status","fromCache","keyedPlan","scanLimit","scanRequest","scanRead","scanCurrent","scanOpen","sorted","scan","scanSettled","idOf","row","String","widenTo","previous","setPage","next","setPageSize","errored","hasMore","settled","ordered","error","serverDenied"],"mappings":"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"}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { type DocumentData, type Firestore, type WhereFilterOp } from 'firebase/firestore';
|
|
2
|
+
import { type DependencyList } from 'react';
|
|
3
|
+
/**
|
|
4
|
+
* Collection KEY for the session-health verdict (AGL-2486).
|
|
5
|
+
*
|
|
6
|
+
* The even path segments only — `['users', uid, 'hostMemberships']` becomes
|
|
7
|
+
* `users/hostMemberships`. That is the convention `use-org-hosts` already
|
|
8
|
+
* uses for the same collection, so the two report the SAME key and still
|
|
9
|
+
* count as one collection rather than inflating each other toward the
|
|
10
|
+
* two-collection threshold. It also keeps document ids — a uid among them —
|
|
11
|
+
* out of a module-scope map.
|
|
12
|
+
*/
|
|
13
|
+
export declare function switcherCollectionKey(path: string[]): string;
|
|
14
|
+
export interface UseSwitcherCollectionOptions<T> {
|
|
15
|
+
firestore: Firestore;
|
|
16
|
+
/** Collection path segments, e.g. `['hosts', hostId, 'screens']`. */
|
|
17
|
+
path: string[];
|
|
18
|
+
/** Optional single filter, e.g. `['orgId', '==', orgId]`. */
|
|
19
|
+
where?: readonly [string, WhereFilterOp, unknown];
|
|
20
|
+
/** Raw search text from the field; debounced and normalized internally. */
|
|
21
|
+
query: string;
|
|
22
|
+
/** Recent-first window size when the query is empty (default 10). */
|
|
23
|
+
idleLimit?: number;
|
|
24
|
+
/** Result cap for the name-prefix search (default 20). */
|
|
25
|
+
searchLimit?: number;
|
|
26
|
+
/**
|
|
27
|
+
* The field holding the human-readable name, matched client-side.
|
|
28
|
+
*
|
|
29
|
+
* Both switchers store it as `displayName`, which is the default; a caller
|
|
30
|
+
* whose rows name themselves differently passes its own.
|
|
31
|
+
*/
|
|
32
|
+
nameField?: string;
|
|
33
|
+
/**
|
|
34
|
+
* How many documents the client-side pass reads (default 50).
|
|
35
|
+
*
|
|
36
|
+
* Fetched ONCE per scope and cached for the life of the mount, so typing
|
|
37
|
+
* costs nothing after the first character — see the search-mode note on the
|
|
38
|
+
* hook itself.
|
|
39
|
+
*/
|
|
40
|
+
searchWindow?: number;
|
|
41
|
+
/** Field written with the doc id (default `$id`). */
|
|
42
|
+
idField?: string;
|
|
43
|
+
/** Client post-filter, e.g. drop soft-deleted or email screens. */
|
|
44
|
+
filter?: (item: T) => boolean;
|
|
45
|
+
/** Stable scope dependencies (firestore, ids) — like a useEffect dep array. */
|
|
46
|
+
deps: DependencyList;
|
|
47
|
+
/** Debounce for the search query in ms (default 200). */
|
|
48
|
+
debounceMs?: number;
|
|
49
|
+
/**
|
|
50
|
+
* Hold off entirely, because a value this listen must be SCOPED BY is not
|
|
51
|
+
* resolved yet (AGL-2350).
|
|
52
|
+
*
|
|
53
|
+
* The empty-path-segment hold-off below covers a scope that appears IN the
|
|
54
|
+
* path. A `where` filter is the other kind, and it fails the opposite way:
|
|
55
|
+
* an unresolved id makes `where` `undefined`, which does not error — it
|
|
56
|
+
* silently drops the filter and returns the UNSCOPED collection. For
|
|
57
|
+
* `users/{uid}/hostMemberships` that is every site the person holds in
|
|
58
|
+
* every org, which on an agency running one workspace per client puts two
|
|
59
|
+
* clients' site names in one dropdown.
|
|
60
|
+
*
|
|
61
|
+
* Defaults to `false`, so this is inert for any caller that does not scope
|
|
62
|
+
* by a filter.
|
|
63
|
+
*/
|
|
64
|
+
skip?: boolean;
|
|
65
|
+
}
|
|
66
|
+
export interface UseSwitcherCollectionResult<T> {
|
|
67
|
+
items: T[];
|
|
68
|
+
/** A fetch is in flight and there are no prior rows to show yet. */
|
|
69
|
+
loading: boolean;
|
|
70
|
+
/** The (debounced) query is non-empty — results are name-search matches. */
|
|
71
|
+
hasQuery: boolean;
|
|
72
|
+
/**
|
|
73
|
+
* The most recent fetch FAILED (AGL-1066).
|
|
74
|
+
*
|
|
75
|
+
* Keeping the prior rows on a failure is right and stays (see the catch
|
|
76
|
+
* below), but the failure was previously erased along with `loading` — so
|
|
77
|
+
* the first fetch of a scope, which has no prior rows, settled on
|
|
78
|
+
* `{ items: [], loading: false }` and every switcher read that as "this
|
|
79
|
+
* person holds no sites". Consumers must render "couldn't load", never a
|
|
80
|
+
* zero-state, when this is true.
|
|
81
|
+
*/
|
|
82
|
+
error: boolean;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Data source for the org/site/screen switchers that scales past a switcher's
|
|
86
|
+
* naive "load the whole collection and filter in memory" (AGL-838). Two modes:
|
|
87
|
+
*
|
|
88
|
+
* - **idle** (empty query): a recent-first window, `orderBy(updatedAt desc)`
|
|
89
|
+
* capped at `idleLimit` — the handful of docs you actually cycle between, not
|
|
90
|
+
* a full read.
|
|
91
|
+
* - **search** (non-empty query): a true Firestore prefix range over the
|
|
92
|
+
* normalized `nameLower` (`orderBy(nameLower) startAt(key)..endAt(key+'\uf8ff')`),
|
|
93
|
+
* capped at `searchLimit` — finds a match anywhere in the collection without
|
|
94
|
+
* loading it, where the old client-side filter could only match already-
|
|
95
|
+
* loaded rows.
|
|
96
|
+
*
|
|
97
|
+
* Reads are one-shot `getDocs` (a switcher list doesn't need live updates), so
|
|
98
|
+
* there is no per-keystroke listener churn and no flash: prior rows stay
|
|
99
|
+
* visible while the next fetch is in flight, and only a scope change (a
|
|
100
|
+
* dependency in `deps`) clears them — the same hold-the-right-scope rule as
|
|
101
|
+
* `useFirestoreCollection` (AGL-591), without its clear-to-empty-then-refill.
|
|
102
|
+
* Out-of-order responses are dropped by request id.
|
|
103
|
+
*/
|
|
104
|
+
export declare function useSwitcherCollection<T = DocumentData>(options: UseSwitcherCollectionOptions<T>): UseSwitcherCollectionResult<T>;
|
|
105
|
+
export default useSwitcherCollection;
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ 'use client';
|
|
17
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
18
|
+
import { compareScored, nameSearchKey, scoreMatch } from "@aglyn/aglyn";
|
|
19
|
+
import { collection, documentId, endAt, getDocs, limit, orderBy, query, startAt, where } from "firebase/firestore";
|
|
20
|
+
import { useEffect, useRef, useState } from "react";
|
|
21
|
+
import { reportFirestoreDenial, reportFirestoreServerRead } from "./firestore-denial-reporter.js";
|
|
22
|
+
/**
|
|
23
|
+
* Collection KEY for the session-health verdict (AGL-2486).
|
|
24
|
+
*
|
|
25
|
+
* The even path segments only — `['users', uid, 'hostMemberships']` becomes
|
|
26
|
+
* `users/hostMemberships`. That is the convention `use-org-hosts` already
|
|
27
|
+
* uses for the same collection, so the two report the SAME key and still
|
|
28
|
+
* count as one collection rather than inflating each other toward the
|
|
29
|
+
* two-collection threshold. It also keeps document ids — a uid among them —
|
|
30
|
+
* out of a module-scope map.
|
|
31
|
+
*/ export function switcherCollectionKey(path) {
|
|
32
|
+
return path.filter((_, index)=>index % 2 === 0).join('/');
|
|
33
|
+
}
|
|
34
|
+
function useDebouncedValue(value, ms) {
|
|
35
|
+
const [debounced, setDebounced] = useState(value);
|
|
36
|
+
useEffect(()=>{
|
|
37
|
+
const timer = setTimeout(()=>setDebounced(value), ms);
|
|
38
|
+
return ()=>clearTimeout(timer);
|
|
39
|
+
}, [
|
|
40
|
+
value,
|
|
41
|
+
ms
|
|
42
|
+
]);
|
|
43
|
+
return debounced;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Data source for the org/site/screen switchers that scales past a switcher's
|
|
47
|
+
* naive "load the whole collection and filter in memory" (AGL-838). Two modes:
|
|
48
|
+
*
|
|
49
|
+
* - **idle** (empty query): a recent-first window, `orderBy(updatedAt desc)`
|
|
50
|
+
* capped at `idleLimit` — the handful of docs you actually cycle between, not
|
|
51
|
+
* a full read.
|
|
52
|
+
* - **search** (non-empty query): a true Firestore prefix range over the
|
|
53
|
+
* normalized `nameLower` (`orderBy(nameLower) startAt(key)..endAt(key+'\uf8ff')`),
|
|
54
|
+
* capped at `searchLimit` — finds a match anywhere in the collection without
|
|
55
|
+
* loading it, where the old client-side filter could only match already-
|
|
56
|
+
* loaded rows.
|
|
57
|
+
*
|
|
58
|
+
* Reads are one-shot `getDocs` (a switcher list doesn't need live updates), so
|
|
59
|
+
* there is no per-keystroke listener churn and no flash: prior rows stay
|
|
60
|
+
* visible while the next fetch is in flight, and only a scope change (a
|
|
61
|
+
* dependency in `deps`) clears them — the same hold-the-right-scope rule as
|
|
62
|
+
* `useFirestoreCollection` (AGL-591), without its clear-to-empty-then-refill.
|
|
63
|
+
* Out-of-order responses are dropped by request id.
|
|
64
|
+
*/ export function useSwitcherCollection(options) {
|
|
65
|
+
const { firestore, path, where: whereClause, query: rawQuery, idleLimit = 10, searchLimit = 20, nameField = 'displayName', searchWindow = 50, idField = '$id', filter, deps, debounceMs = 200, skip = false } = options;
|
|
66
|
+
const debounced = useDebouncedValue(rawQuery.trim(), debounceMs);
|
|
67
|
+
const key = nameSearchKey(debounced);
|
|
68
|
+
const hasQuery = key.length > 0;
|
|
69
|
+
const [items, setItems] = useState([]);
|
|
70
|
+
const [loading, setLoading] = useState(true);
|
|
71
|
+
const [error, setError] = useState(false);
|
|
72
|
+
const requestRef = useRef(0);
|
|
73
|
+
/**
|
|
74
|
+
* The client-side search window, cached for the life of this SCOPE.
|
|
75
|
+
*
|
|
76
|
+
* A ref rather than state: filling it must not itself cause a render, and
|
|
77
|
+
* the render that matters is the one `setItems` already causes. Cleared by
|
|
78
|
+
* the scope effect below, which is the same place the rows are cleared —
|
|
79
|
+
* keeping the two together is what stops site A's window being matched
|
|
80
|
+
* against while standing on site B.
|
|
81
|
+
*/ const windowRef = useRef(null);
|
|
82
|
+
// Clear on a genuine scope change (host A → host B) so the previous scope's
|
|
83
|
+
// rows never bleed into the new one; a query change keeps the prior rows so
|
|
84
|
+
// the list doesn't flash (AGL-591 rule, applied per-scope not per-fetch).
|
|
85
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
86
|
+
useEffect(()=>{
|
|
87
|
+
setItems([]);
|
|
88
|
+
setLoading(true);
|
|
89
|
+
setError(false);
|
|
90
|
+
windowRef.current = null;
|
|
91
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
92
|
+
}, deps);
|
|
93
|
+
useEffect(()=>{
|
|
94
|
+
const requestId = ++requestRef.current;
|
|
95
|
+
// A path segment can be momentarily empty (e.g. the uid before auth
|
|
96
|
+
// resolves); hold rather than build an invalid collection ref. `skip` is
|
|
97
|
+
// the same hold for a scope that lives in `where` rather than the path,
|
|
98
|
+
// where an unresolved id would widen the query instead of breaking it.
|
|
99
|
+
if (skip || path.some((segment)=>!segment)) {
|
|
100
|
+
setItems([]);
|
|
101
|
+
setLoading(true);
|
|
102
|
+
setError(false);
|
|
103
|
+
// A hold means the scope is not trustworthy yet, so anything already
|
|
104
|
+
// read under it must not survive into the scope that unblocks.
|
|
105
|
+
windowRef.current = null;
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
setLoading(true);
|
|
109
|
+
const ref = collection(firestore, path[0], ...path.slice(1));
|
|
110
|
+
const scopeFilter = whereClause ? [
|
|
111
|
+
where(whereClause[0], whereClause[1], whereClause[2])
|
|
112
|
+
] : [];
|
|
113
|
+
const toRows = (snapshot)=>snapshot.docs.map((docSnap)=>{
|
|
114
|
+
const value = _extends({}, docSnap.data());
|
|
115
|
+
if (idField) value[idField] = docSnap.id;
|
|
116
|
+
return value;
|
|
117
|
+
});
|
|
118
|
+
/**
|
|
119
|
+
* SEARCH MODE (AGL-2486) — a client-side window first, the prefix range
|
|
120
|
+
* only when the window cannot have held everything.
|
|
121
|
+
*
|
|
122
|
+
* This used to be the prefix range alone, and that had two defects which
|
|
123
|
+
* look like one from the outside.
|
|
124
|
+
*
|
|
125
|
+
* **It hid documents.** `orderBy('nameLower')` makes Firestore OMIT every
|
|
126
|
+
* document that does not carry the field — and `nameLower` is optional,
|
|
127
|
+
* stamped by three write paths for one resource kind. A document written
|
|
128
|
+
* any other way stayed visible in the idle list and vanished the instant
|
|
129
|
+
* you typed. Measured against the seeded emulator: a screen named "Home"
|
|
130
|
+
* listed, and `home` returned nothing. Production happens to be clean
|
|
131
|
+
* today (68/68 live screens and 10/10 membership rows carry it, checked
|
|
132
|
+
* with the Admin SDK), so this is a LATENT defect rather than a firing
|
|
133
|
+
* one — but it is armed for the next write path that forgets, and nothing
|
|
134
|
+
* would fail when it does. Ordering the window by `documentId()`, which no
|
|
135
|
+
* document can be missing, disarms it permanently: correctness stops
|
|
136
|
+
* depending on a field staying in step across write paths.
|
|
137
|
+
*
|
|
138
|
+
* **And it could not match the way people type.** A prefix over the whole
|
|
139
|
+
* stored name means somebody looking for "Main Layout" who types `layout`
|
|
140
|
+
* gets nothing. That one IS firing, on every surface, today.
|
|
141
|
+
*
|
|
142
|
+
* The window is read ONCE per scope and cached for the life of the mount,
|
|
143
|
+
* so the common case — a site whose collection fits inside it — costs one
|
|
144
|
+
* read burst and then nothing at all per keystroke, which is CHEAPER than
|
|
145
|
+
* the query it replaces. The prefix range is kept and issued only when the
|
|
146
|
+
* window came back full, because that is the case AGL-838 built it for: a
|
|
147
|
+
* host with hundreds of screens must still find one that is not in the
|
|
148
|
+
* window. Dropping it to simplify would have regressed exactly the
|
|
149
|
+
* property the switcher exists to provide.
|
|
150
|
+
*/ const searchRead = async ()=>{
|
|
151
|
+
let windowRows = windowRef.current;
|
|
152
|
+
if (!windowRows) {
|
|
153
|
+
var _windowSnapshot_metadata;
|
|
154
|
+
const windowSnapshot = await getDocs(query(ref, ...scopeFilter, orderBy(documentId()), limit(searchWindow)));
|
|
155
|
+
windowRows = {
|
|
156
|
+
rows: toRows(windowSnapshot),
|
|
157
|
+
fromCache: (_windowSnapshot_metadata = windowSnapshot.metadata) == null ? void 0 : _windowSnapshot_metadata.fromCache
|
|
158
|
+
};
|
|
159
|
+
windowRef.current = windowRows;
|
|
160
|
+
}
|
|
161
|
+
const scored = windowRows.rows.map((row)=>{
|
|
162
|
+
var _ref;
|
|
163
|
+
const label = String((_ref = row == null ? void 0 : row[nameField]) != null ? _ref : '');
|
|
164
|
+
const score = scoreMatch({
|
|
165
|
+
name: label
|
|
166
|
+
}, debounced);
|
|
167
|
+
return score === null ? null : {
|
|
168
|
+
row,
|
|
169
|
+
score,
|
|
170
|
+
label
|
|
171
|
+
};
|
|
172
|
+
}).filter(Boolean);
|
|
173
|
+
// Only reach past the window when the window could not have held the
|
|
174
|
+
// whole collection. A partial window is proof there is nothing beyond it.
|
|
175
|
+
let beyond = [];
|
|
176
|
+
let beyondFromCache;
|
|
177
|
+
if (windowRows.rows.length >= searchWindow) {
|
|
178
|
+
var _prefixSnapshot_metadata;
|
|
179
|
+
const prefixSnapshot = await getDocs(query(ref, ...scopeFilter, orderBy('nameLower'), startAt(key), endAt(key + '\uf8ff'), limit(searchLimit)));
|
|
180
|
+
beyondFromCache = (_prefixSnapshot_metadata = prefixSnapshot.metadata) == null ? void 0 : _prefixSnapshot_metadata.fromCache;
|
|
181
|
+
beyond = toRows(prefixSnapshot);
|
|
182
|
+
}
|
|
183
|
+
const seen = new Set(scored.map((hit)=>{
|
|
184
|
+
var _hit_row;
|
|
185
|
+
return (_hit_row = hit.row) == null ? void 0 : _hit_row[idField];
|
|
186
|
+
}));
|
|
187
|
+
const merged = [
|
|
188
|
+
...scored.sort((a, b)=>compareScored(a, b)).map((hit)=>hit.row),
|
|
189
|
+
...beyond.filter((row)=>!seen.has(row == null ? void 0 : row[idField]))
|
|
190
|
+
];
|
|
191
|
+
return {
|
|
192
|
+
rows: merged.slice(0, searchLimit),
|
|
193
|
+
fromCache: windowRows.fromCache === false ? false : beyondFromCache
|
|
194
|
+
};
|
|
195
|
+
};
|
|
196
|
+
const read = hasQuery ? searchRead() : getDocs(query(ref, ...scopeFilter, orderBy('updatedAt', 'desc'), limit(idleLimit))).then((snapshot)=>{
|
|
197
|
+
var _snapshot_metadata;
|
|
198
|
+
return {
|
|
199
|
+
rows: toRows(snapshot),
|
|
200
|
+
fromCache: (_snapshot_metadata = snapshot.metadata) == null ? void 0 : _snapshot_metadata.fromCache
|
|
201
|
+
};
|
|
202
|
+
});
|
|
203
|
+
read.then((result)=>{
|
|
204
|
+
var _snapshot_metadata;
|
|
205
|
+
if (requestRef.current !== requestId) return; // superseded
|
|
206
|
+
const snapshot = {
|
|
207
|
+
metadata: {
|
|
208
|
+
fromCache: result.fromCache
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
const rows = result.rows;
|
|
212
|
+
setItems(filter ? rows.filter(filter) : rows);
|
|
213
|
+
setError(false);
|
|
214
|
+
setLoading(false);
|
|
215
|
+
// A SERVER answer is proof the session can read, and it clears the
|
|
216
|
+
// denial evidence outright (AGL-2486). Guarded on `fromCache`
|
|
217
|
+
// because `getDocs` falls back to the cache while offline, and the
|
|
218
|
+
// reporter contract is explicit that a cached snapshot proves
|
|
219
|
+
// nothing.
|
|
220
|
+
//
|
|
221
|
+
// Written as `=== false` rather than `!fromCache` on purpose: the
|
|
222
|
+
// claim being made is "the server answered", so absent metadata
|
|
223
|
+
// must report NOTHING rather than be read as a server read. It also
|
|
224
|
+
// keeps this branch from throwing on a snapshot shape it did not
|
|
225
|
+
// expect — a throw here lands in the `catch` below and would show
|
|
226
|
+
// the user a refusal for a fetch that actually succeeded.
|
|
227
|
+
if (((_snapshot_metadata = snapshot.metadata) == null ? void 0 : _snapshot_metadata.fromCache) === false) reportFirestoreServerRead();
|
|
228
|
+
}).catch((error)=>{
|
|
229
|
+
// A missing composite index or transient error leaves the prior rows
|
|
230
|
+
// in place rather than blanking the menu; the caller's "view all"
|
|
231
|
+
// escape hatch still reaches everything.
|
|
232
|
+
//
|
|
233
|
+
// But SAY SO (AGL-1066). Swallowing the failure into `loading: false`
|
|
234
|
+
// meant the no-prior-rows case — a cold load, or the first fetch
|
|
235
|
+
// after a scope change — was indistinguishable from a genuinely
|
|
236
|
+
// empty collection, and the site switcher printed "No sites yet."
|
|
237
|
+
// at people who hold sites.
|
|
238
|
+
if (requestRef.current !== requestId) return;
|
|
239
|
+
setError(true);
|
|
240
|
+
setLoading(false);
|
|
241
|
+
// …and tell the session detector, which this read used to keep to
|
|
242
|
+
// itself (AGL-2486). the production report was a Sites list that
|
|
243
|
+
// "could not be loaded" while the console had no idea the session
|
|
244
|
+
// was the reason: the switcher is one of the first reads on the
|
|
245
|
+
// page and it contributed ZERO evidence toward the stale verdict,
|
|
246
|
+
// so the very list that failed could never be what raised the
|
|
247
|
+
// prompt. Only `permission-denied` — a missing index
|
|
248
|
+
// (`failed-precondition`) or a dropped network (`unavailable`) is
|
|
249
|
+
// not a session problem and must never be counted as one.
|
|
250
|
+
if ((error == null ? void 0 : error.code) === 'permission-denied') {
|
|
251
|
+
reportFirestoreDenial(switcherCollectionKey(path));
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
255
|
+
// `skip` is listed even though it is derived from a value already in
|
|
256
|
+
// `deps` for every caller today — a future caller whose skip condition is
|
|
257
|
+
// independent of its scope would otherwise stay held after unblocking.
|
|
258
|
+
}, [
|
|
259
|
+
...deps,
|
|
260
|
+
key,
|
|
261
|
+
hasQuery,
|
|
262
|
+
idleLimit,
|
|
263
|
+
searchLimit,
|
|
264
|
+
skip
|
|
265
|
+
]);
|
|
266
|
+
return {
|
|
267
|
+
items,
|
|
268
|
+
loading,
|
|
269
|
+
hasQuery,
|
|
270
|
+
error
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
export default useSwitcherCollection;
|
|
274
|
+
|
|
275
|
+
//# sourceMappingURL=use-switcher-collection.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/use-switcher-collection.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { compareScored, nameSearchKey, scoreMatch } from '@aglyn/aglyn'\nimport {\n collection,\n documentId,\n endAt,\n type DocumentData,\n type Firestore,\n getDocs,\n limit,\n orderBy,\n query,\n startAt,\n where,\n type WhereFilterOp,\n} from 'firebase/firestore'\nimport {\n type DependencyList,\n useEffect,\n useRef,\n useState,\n} from 'react'\nimport {\n reportFirestoreDenial,\n reportFirestoreServerRead,\n} from './firestore-denial-reporter'\n\n/**\n * Collection KEY for the session-health verdict (AGL-2486).\n *\n * The even path segments only — `['users', uid, 'hostMemberships']` becomes\n * `users/hostMemberships`. That is the convention `use-org-hosts` already\n * uses for the same collection, so the two report the SAME key and still\n * count as one collection rather than inflating each other toward the\n * two-collection threshold. It also keeps document ids — a uid among them —\n * out of a module-scope map.\n */\nexport function switcherCollectionKey(path: string[]): string {\n return path.filter((_, index) => index % 2 === 0).join('/')\n}\n\nexport interface UseSwitcherCollectionOptions<T> {\n firestore: Firestore\n /** Collection path segments, e.g. `['hosts', hostId, 'screens']`. */\n path: string[]\n /** Optional single filter, e.g. `['orgId', '==', orgId]`. */\n where?: readonly [string, WhereFilterOp, unknown]\n /** Raw search text from the field; debounced and normalized internally. */\n query: string\n /** Recent-first window size when the query is empty (default 10). */\n idleLimit?: number\n /** Result cap for the name-prefix search (default 20). */\n searchLimit?: number\n /**\n * The field holding the human-readable name, matched client-side.\n *\n * Both switchers store it as `displayName`, which is the default; a caller\n * whose rows name themselves differently passes its own.\n */\n nameField?: string\n /**\n * How many documents the client-side pass reads (default 50).\n *\n * Fetched ONCE per scope and cached for the life of the mount, so typing\n * costs nothing after the first character — see the search-mode note on the\n * hook itself.\n */\n searchWindow?: number\n /** Field written with the doc id (default `$id`). */\n idField?: string\n /** Client post-filter, e.g. drop soft-deleted or email screens. */\n filter?: (item: T) => boolean\n /** Stable scope dependencies (firestore, ids) — like a useEffect dep array. */\n deps: DependencyList\n /** Debounce for the search query in ms (default 200). */\n debounceMs?: number\n /**\n * Hold off entirely, because a value this listen must be SCOPED BY is not\n * resolved yet (AGL-2350).\n *\n * The empty-path-segment hold-off below covers a scope that appears IN the\n * path. A `where` filter is the other kind, and it fails the opposite way:\n * an unresolved id makes `where` `undefined`, which does not error — it\n * silently drops the filter and returns the UNSCOPED collection. For\n * `users/{uid}/hostMemberships` that is every site the person holds in\n * every org, which on an agency running one workspace per client puts two\n * clients' site names in one dropdown.\n *\n * Defaults to `false`, so this is inert for any caller that does not scope\n * by a filter.\n */\n skip?: boolean\n}\n\nexport interface UseSwitcherCollectionResult<T> {\n items: T[]\n /** A fetch is in flight and there are no prior rows to show yet. */\n loading: boolean\n /** The (debounced) query is non-empty — results are name-search matches. */\n hasQuery: boolean\n /**\n * The most recent fetch FAILED (AGL-1066).\n *\n * Keeping the prior rows on a failure is right and stays (see the catch\n * below), but the failure was previously erased along with `loading` — so\n * the first fetch of a scope, which has no prior rows, settled on\n * `{ items: [], loading: false }` and every switcher read that as \"this\n * person holds no sites\". Consumers must render \"couldn't load\", never a\n * zero-state, when this is true.\n */\n error: boolean\n}\n\nfunction useDebouncedValue<V>(value: V, ms: number): V {\n const [debounced, setDebounced] = useState(value)\n useEffect(() => {\n const timer = setTimeout(() => setDebounced(value), ms)\n return () => clearTimeout(timer)\n }, [value, ms])\n return debounced\n}\n\n/**\n * Data source for the org/site/screen switchers that scales past a switcher's\n * naive \"load the whole collection and filter in memory\" (AGL-838). Two modes:\n *\n * - **idle** (empty query): a recent-first window, `orderBy(updatedAt desc)`\n * capped at `idleLimit` — the handful of docs you actually cycle between, not\n * a full read.\n * - **search** (non-empty query): a true Firestore prefix range over the\n * normalized `nameLower` (`orderBy(nameLower) startAt(key)..endAt(key+'\\uf8ff')`),\n * capped at `searchLimit` — finds a match anywhere in the collection without\n * loading it, where the old client-side filter could only match already-\n * loaded rows.\n *\n * Reads are one-shot `getDocs` (a switcher list doesn't need live updates), so\n * there is no per-keystroke listener churn and no flash: prior rows stay\n * visible while the next fetch is in flight, and only a scope change (a\n * dependency in `deps`) clears them — the same hold-the-right-scope rule as\n * `useFirestoreCollection` (AGL-591), without its clear-to-empty-then-refill.\n * Out-of-order responses are dropped by request id.\n */\nexport function useSwitcherCollection<T = DocumentData>(\n options: UseSwitcherCollectionOptions<T>,\n): UseSwitcherCollectionResult<T> {\n const {\n firestore,\n path,\n where: whereClause,\n query: rawQuery,\n idleLimit = 10,\n searchLimit = 20,\n nameField = 'displayName',\n searchWindow = 50,\n idField = '$id',\n filter,\n deps,\n debounceMs = 200,\n skip = false,\n } = options\n\n const debounced = useDebouncedValue(rawQuery.trim(), debounceMs)\n const key = nameSearchKey(debounced)\n const hasQuery = key.length > 0\n\n const [items, setItems] = useState<T[]>([])\n const [loading, setLoading] = useState(true)\n const [error, setError] = useState(false)\n const requestRef = useRef(0)\n /**\n * The client-side search window, cached for the life of this SCOPE.\n *\n * A ref rather than state: filling it must not itself cause a render, and\n * the render that matters is the one `setItems` already causes. Cleared by\n * the scope effect below, which is the same place the rows are cleared —\n * keeping the two together is what stops site A's window being matched\n * against while standing on site B.\n */\n const windowRef = useRef<{ rows: any[]; fromCache?: boolean } | null>(null)\n\n // Clear on a genuine scope change (host A → host B) so the previous scope's\n // rows never bleed into the new one; a query change keeps the prior rows so\n // the list doesn't flash (AGL-591 rule, applied per-scope not per-fetch).\n // eslint-disable-next-line react-hooks/exhaustive-deps\n useEffect(() => {\n setItems([])\n setLoading(true)\n setError(false)\n windowRef.current = null\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, deps)\n\n useEffect(() => {\n const requestId = ++requestRef.current\n // A path segment can be momentarily empty (e.g. the uid before auth\n // resolves); hold rather than build an invalid collection ref. `skip` is\n // the same hold for a scope that lives in `where` rather than the path,\n // where an unresolved id would widen the query instead of breaking it.\n if (skip || path.some((segment) => !segment)) {\n setItems([])\n setLoading(true)\n setError(false)\n // A hold means the scope is not trustworthy yet, so anything already\n // read under it must not survive into the scope that unblocks.\n windowRef.current = null\n return\n }\n setLoading(true)\n const ref = collection(firestore, path[0], ...path.slice(1))\n const scopeFilter = whereClause\n ? [where(whereClause[0], whereClause[1], whereClause[2])]\n : []\n const toRows = (snapshot: { docs: any[] }) =>\n snapshot.docs.map((docSnap) => {\n const value = { ...docSnap.data() } as Record<string, unknown>\n if (idField) value[idField] = docSnap.id\n return value as T\n })\n\n /**\n * SEARCH MODE (AGL-2486) — a client-side window first, the prefix range\n * only when the window cannot have held everything.\n *\n * This used to be the prefix range alone, and that had two defects which\n * look like one from the outside.\n *\n * **It hid documents.** `orderBy('nameLower')` makes Firestore OMIT every\n * document that does not carry the field — and `nameLower` is optional,\n * stamped by three write paths for one resource kind. A document written\n * any other way stayed visible in the idle list and vanished the instant\n * you typed. Measured against the seeded emulator: a screen named \"Home\"\n * listed, and `home` returned nothing. Production happens to be clean\n * today (68/68 live screens and 10/10 membership rows carry it, checked\n * with the Admin SDK), so this is a LATENT defect rather than a firing\n * one — but it is armed for the next write path that forgets, and nothing\n * would fail when it does. Ordering the window by `documentId()`, which no\n * document can be missing, disarms it permanently: correctness stops\n * depending on a field staying in step across write paths.\n *\n * **And it could not match the way people type.** A prefix over the whole\n * stored name means somebody looking for \"Main Layout\" who types `layout`\n * gets nothing. That one IS firing, on every surface, today.\n *\n * The window is read ONCE per scope and cached for the life of the mount,\n * so the common case — a site whose collection fits inside it — costs one\n * read burst and then nothing at all per keystroke, which is CHEAPER than\n * the query it replaces. The prefix range is kept and issued only when the\n * window came back full, because that is the case AGL-838 built it for: a\n * host with hundreds of screens must still find one that is not in the\n * window. Dropping it to simplify would have regressed exactly the\n * property the switcher exists to provide.\n */\n const searchRead = async () => {\n let windowRows = windowRef.current\n if (!windowRows) {\n const windowSnapshot = await getDocs(\n query(ref, ...scopeFilter, orderBy(documentId()), limit(searchWindow)),\n )\n windowRows = {\n rows: toRows(windowSnapshot),\n fromCache: windowSnapshot.metadata?.fromCache,\n }\n windowRef.current = windowRows\n }\n const scored = windowRows.rows\n .map((row: any) => {\n const label = String(row?.[nameField] ?? '')\n const score = scoreMatch({ name: label }, debounced)\n return score === null ? null : { row, score, label }\n })\n .filter(Boolean) as Array<{ row: T; score: number; label: string }>\n\n // Only reach past the window when the window could not have held the\n // whole collection. A partial window is proof there is nothing beyond it.\n let beyond: T[] = []\n let beyondFromCache: boolean | undefined\n if (windowRows.rows.length >= searchWindow) {\n const prefixSnapshot = await getDocs(\n query(\n ref,\n ...scopeFilter,\n orderBy('nameLower'),\n startAt(key),\n endAt(key + '\\uf8ff'),\n limit(searchLimit),\n ),\n )\n beyondFromCache = prefixSnapshot.metadata?.fromCache\n beyond = toRows(prefixSnapshot)\n }\n\n const seen = new Set(scored.map((hit) => (hit.row as any)?.[idField]))\n const merged = [\n ...scored\n .sort((a, b) => compareScored(a, b))\n .map((hit) => hit.row),\n ...beyond.filter((row: any) => !seen.has(row?.[idField])),\n ]\n return {\n rows: merged.slice(0, searchLimit),\n fromCache: windowRows.fromCache === false ? false : beyondFromCache,\n }\n }\n\n const read = hasQuery\n ? searchRead()\n : getDocs(\n query(ref, ...scopeFilter, orderBy('updatedAt', 'desc'), limit(idleLimit)),\n ).then((snapshot) => ({\n rows: toRows(snapshot),\n fromCache: snapshot.metadata?.fromCache,\n }))\n\n read\n .then((result) => {\n if (requestRef.current !== requestId) return // superseded\n const snapshot = { metadata: { fromCache: result.fromCache } }\n const rows = result.rows\n setItems(filter ? rows.filter(filter) : rows)\n setError(false)\n setLoading(false)\n // A SERVER answer is proof the session can read, and it clears the\n // denial evidence outright (AGL-2486). Guarded on `fromCache`\n // because `getDocs` falls back to the cache while offline, and the\n // reporter contract is explicit that a cached snapshot proves\n // nothing.\n //\n // Written as `=== false` rather than `!fromCache` on purpose: the\n // claim being made is \"the server answered\", so absent metadata\n // must report NOTHING rather than be read as a server read. It also\n // keeps this branch from throwing on a snapshot shape it did not\n // expect — a throw here lands in the `catch` below and would show\n // the user a refusal for a fetch that actually succeeded.\n if (snapshot.metadata?.fromCache === false) reportFirestoreServerRead()\n })\n .catch((error: unknown) => {\n // A missing composite index or transient error leaves the prior rows\n // in place rather than blanking the menu; the caller's \"view all\"\n // escape hatch still reaches everything.\n //\n // But SAY SO (AGL-1066). Swallowing the failure into `loading: false`\n // meant the no-prior-rows case — a cold load, or the first fetch\n // after a scope change — was indistinguishable from a genuinely\n // empty collection, and the site switcher printed \"No sites yet.\"\n // at people who hold sites.\n if (requestRef.current !== requestId) return\n setError(true)\n setLoading(false)\n // …and tell the session detector, which this read used to keep to\n // itself (AGL-2486). the production report was a Sites list that\n // \"could not be loaded\" while the console had no idea the session\n // was the reason: the switcher is one of the first reads on the\n // page and it contributed ZERO evidence toward the stale verdict,\n // so the very list that failed could never be what raised the\n // prompt. Only `permission-denied` — a missing index\n // (`failed-precondition`) or a dropped network (`unavailable`) is\n // not a session problem and must never be counted as one.\n if ((error as { code?: string })?.code === 'permission-denied') {\n reportFirestoreDenial(switcherCollectionKey(path))\n }\n })\n // eslint-disable-next-line react-hooks/exhaustive-deps\n // `skip` is listed even though it is derived from a value already in\n // `deps` for every caller today — a future caller whose skip condition is\n // independent of its scope would otherwise stay held after unblocking.\n }, [...deps, key, hasQuery, idleLimit, searchLimit, skip])\n\n return { items, loading, hasQuery, error }\n}\n\nexport default useSwitcherCollection\n"],"names":["compareScored","nameSearchKey","scoreMatch","collection","documentId","endAt","getDocs","limit","orderBy","query","startAt","where","useEffect","useRef","useState","reportFirestoreDenial","reportFirestoreServerRead","switcherCollectionKey","path","filter","_","index","join","useDebouncedValue","value","ms","debounced","setDebounced","timer","setTimeout","clearTimeout","useSwitcherCollection","options","firestore","whereClause","rawQuery","idleLimit","searchLimit","nameField","searchWindow","idField","deps","debounceMs","skip","trim","key","hasQuery","length","items","setItems","loading","setLoading","error","setError","requestRef","windowRef","current","requestId","some","segment","ref","slice","scopeFilter","toRows","snapshot","docs","map","docSnap","data","id","searchRead","windowRows","windowSnapshot","rows","fromCache","metadata","scored","row","label","String","score","name","Boolean","beyond","beyondFromCache","prefixSnapshot","seen","Set","hit","merged","sort","a","b","has","read","then","result","catch","code"],"mappings":"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"}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The signed-in user's NAME, resolved the way their photo already is
|
|
3
|
+
* (AGL-2486).
|
|
4
|
+
*
|
|
5
|
+
* ## The defect this closes
|
|
6
|
+
*
|
|
7
|
+
* `user.displayName` is empty for every SSO account: a SAML identity arrives
|
|
8
|
+
* with `displayName: undefined`, `photoURL: undefined` and a single provider
|
|
9
|
+
* entry for the SAML provider, because GCIP leaves the assertion's mapped
|
|
10
|
+
* attributes under `firebase.sign_in_attributes`. A surface that reads that
|
|
11
|
+
* field alone falls through to the email, so it renders the address as the
|
|
12
|
+
* person's NAME and again underneath as their address — and `memberInitials`,
|
|
13
|
+
* given an address with no space in it, yields a single letter where the
|
|
14
|
+
* presence stack beside it shows two.
|
|
15
|
+
*
|
|
16
|
+
* The name is not actually missing. `users/{uid}` holds `firstName` and
|
|
17
|
+
* `lastName`, the org member doc holds a `displayName`, and presence reads the
|
|
18
|
+
* ID token's IdP claims. Three surfaces, three answers to "what is this person
|
|
19
|
+
* called", and the auth record is the only one that is blank for the
|
|
20
|
+
* enterprise tier.
|
|
21
|
+
*
|
|
22
|
+
* ## Why here, and why this order
|
|
23
|
+
*
|
|
24
|
+
* `users/{uid}` is the profile the user themselves edits, so it outranks the
|
|
25
|
+
* provider's copy — exactly the precedence `useUserProfilePhotoUrl` already
|
|
26
|
+
* establishes for the picture, on the same document, through the same
|
|
27
|
+
* listener. This is that hook's sibling and is deliberately shaped like it;
|
|
28
|
+
* two hooks answering "who is signed in" should not disagree about where to
|
|
29
|
+
* look.
|
|
30
|
+
*
|
|
31
|
+
* The auth record is the fallback rather than the lead because it is the one
|
|
32
|
+
* an IdP may never populate. The email is last and is not really a name — it
|
|
33
|
+
* is what the surface shows when nothing else exists, and callers that want
|
|
34
|
+
* to show an address separately should keep doing so.
|
|
35
|
+
*/
|
|
36
|
+
/**
|
|
37
|
+
* The resolution itself, without React (AGL-2486).
|
|
38
|
+
*
|
|
39
|
+
* Pure and exported so the ORDER can be pinned in a spec. The order is the
|
|
40
|
+
* entire content of this hook and the only part that can be wrong — and the
|
|
41
|
+
* case that matters is one no unit test can reach through the hook, because
|
|
42
|
+
* it needs a real SSO session in a GCIP tenant.
|
|
43
|
+
*/
|
|
44
|
+
export declare function resolveUserName(sources: {
|
|
45
|
+
profileFirstName?: string | null;
|
|
46
|
+
profileLastName?: string | null;
|
|
47
|
+
authDisplayName?: string | null;
|
|
48
|
+
email?: string | null;
|
|
49
|
+
}): string;
|
|
50
|
+
export declare function useUserName(): string;
|
|
51
|
+
export default useUserName;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ import { doc } from "firebase/firestore";
|
|
17
|
+
import { useFirestore, useUser } from "./firebase/firebase-services.js";
|
|
18
|
+
import { useFirestoreDoc } from "./use-firestore-doc.js";
|
|
19
|
+
/**
|
|
20
|
+
* The signed-in user's NAME, resolved the way their photo already is
|
|
21
|
+
* (AGL-2486).
|
|
22
|
+
*
|
|
23
|
+
* ## The defect this closes
|
|
24
|
+
*
|
|
25
|
+
* `user.displayName` is empty for every SSO account: a SAML identity arrives
|
|
26
|
+
* with `displayName: undefined`, `photoURL: undefined` and a single provider
|
|
27
|
+
* entry for the SAML provider, because GCIP leaves the assertion's mapped
|
|
28
|
+
* attributes under `firebase.sign_in_attributes`. A surface that reads that
|
|
29
|
+
* field alone falls through to the email, so it renders the address as the
|
|
30
|
+
* person's NAME and again underneath as their address — and `memberInitials`,
|
|
31
|
+
* given an address with no space in it, yields a single letter where the
|
|
32
|
+
* presence stack beside it shows two.
|
|
33
|
+
*
|
|
34
|
+
* The name is not actually missing. `users/{uid}` holds `firstName` and
|
|
35
|
+
* `lastName`, the org member doc holds a `displayName`, and presence reads the
|
|
36
|
+
* ID token's IdP claims. Three surfaces, three answers to "what is this person
|
|
37
|
+
* called", and the auth record is the only one that is blank for the
|
|
38
|
+
* enterprise tier.
|
|
39
|
+
*
|
|
40
|
+
* ## Why here, and why this order
|
|
41
|
+
*
|
|
42
|
+
* `users/{uid}` is the profile the user themselves edits, so it outranks the
|
|
43
|
+
* provider's copy — exactly the precedence `useUserProfilePhotoUrl` already
|
|
44
|
+
* establishes for the picture, on the same document, through the same
|
|
45
|
+
* listener. This is that hook's sibling and is deliberately shaped like it;
|
|
46
|
+
* two hooks answering "who is signed in" should not disagree about where to
|
|
47
|
+
* look.
|
|
48
|
+
*
|
|
49
|
+
* The auth record is the fallback rather than the lead because it is the one
|
|
50
|
+
* an IdP may never populate. The email is last and is not really a name — it
|
|
51
|
+
* is what the surface shows when nothing else exists, and callers that want
|
|
52
|
+
* to show an address separately should keep doing so.
|
|
53
|
+
*/ /**
|
|
54
|
+
* The resolution itself, without React (AGL-2486).
|
|
55
|
+
*
|
|
56
|
+
* Pure and exported so the ORDER can be pinned in a spec. The order is the
|
|
57
|
+
* entire content of this hook and the only part that can be wrong — and the
|
|
58
|
+
* case that matters is one no unit test can reach through the hook, because
|
|
59
|
+
* it needs a real SSO session in a GCIP tenant.
|
|
60
|
+
*/ export function resolveUserName(sources) {
|
|
61
|
+
var _sources_authDisplayName, _sources_email;
|
|
62
|
+
const fromProfile = [
|
|
63
|
+
sources.profileFirstName,
|
|
64
|
+
sources.profileLastName
|
|
65
|
+
].map((part)=>String(part != null ? part : '').trim()).filter(Boolean).join(' ');
|
|
66
|
+
if (fromProfile) return fromProfile;
|
|
67
|
+
const fromAuth = String((_sources_authDisplayName = sources.authDisplayName) != null ? _sources_authDisplayName : '').trim();
|
|
68
|
+
if (fromAuth) return fromAuth;
|
|
69
|
+
return String((_sources_email = sources.email) != null ? _sources_email : '').trim();
|
|
70
|
+
}
|
|
71
|
+
export function useUserName() {
|
|
72
|
+
const firestore = useFirestore();
|
|
73
|
+
const { data: user } = useUser();
|
|
74
|
+
const uid = user == null ? void 0 : user.uid;
|
|
75
|
+
const { data } = useFirestoreDoc(// Null ref until a uid exists, so a signed-out mount opens no listen at
|
|
76
|
+
// all rather than one the rules refuse — same shape as the photo hook.
|
|
77
|
+
()=>uid ? doc(firestore, 'users', uid) : null, [
|
|
78
|
+
firestore,
|
|
79
|
+
uid
|
|
80
|
+
]);
|
|
81
|
+
return resolveUserName({
|
|
82
|
+
profileFirstName: data == null ? void 0 : data.firstName,
|
|
83
|
+
profileLastName: data == null ? void 0 : data.lastName,
|
|
84
|
+
authDisplayName: user == null ? void 0 : user.displayName,
|
|
85
|
+
email: user == null ? void 0 : user.email
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
export default useUserName;
|
|
89
|
+
|
|
90
|
+
//# sourceMappingURL=use-user-name.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/use-user-name.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nimport { doc } from 'firebase/firestore'\nimport { useFirestore, useUser } from './firebase/firebase-services'\nimport { useFirestoreDoc } from './use-firestore-doc'\n\n/**\n * The signed-in user's NAME, resolved the way their photo already is\n * (AGL-2486).\n *\n * ## The defect this closes\n *\n * `user.displayName` is empty for every SSO account: a SAML identity arrives\n * with `displayName: undefined`, `photoURL: undefined` and a single provider\n * entry for the SAML provider, because GCIP leaves the assertion's mapped\n * attributes under `firebase.sign_in_attributes`. A surface that reads that\n * field alone falls through to the email, so it renders the address as the\n * person's NAME and again underneath as their address — and `memberInitials`,\n * given an address with no space in it, yields a single letter where the\n * presence stack beside it shows two.\n *\n * The name is not actually missing. `users/{uid}` holds `firstName` and\n * `lastName`, the org member doc holds a `displayName`, and presence reads the\n * ID token's IdP claims. Three surfaces, three answers to \"what is this person\n * called\", and the auth record is the only one that is blank for the\n * enterprise tier.\n *\n * ## Why here, and why this order\n *\n * `users/{uid}` is the profile the user themselves edits, so it outranks the\n * provider's copy — exactly the precedence `useUserProfilePhotoUrl` already\n * establishes for the picture, on the same document, through the same\n * listener. This is that hook's sibling and is deliberately shaped like it;\n * two hooks answering \"who is signed in\" should not disagree about where to\n * look.\n *\n * The auth record is the fallback rather than the lead because it is the one\n * an IdP may never populate. The email is last and is not really a name — it\n * is what the surface shows when nothing else exists, and callers that want\n * to show an address separately should keep doing so.\n */\n/**\n * The resolution itself, without React (AGL-2486).\n *\n * Pure and exported so the ORDER can be pinned in a spec. The order is the\n * entire content of this hook and the only part that can be wrong — and the\n * case that matters is one no unit test can reach through the hook, because\n * it needs a real SSO session in a GCIP tenant.\n */\nexport function resolveUserName(sources: {\n profileFirstName?: string | null\n profileLastName?: string | null\n authDisplayName?: string | null\n email?: string | null\n}): string {\n const fromProfile = [sources.profileFirstName, sources.profileLastName]\n .map((part) => String(part ?? '').trim())\n .filter(Boolean)\n .join(' ')\n if (fromProfile) return fromProfile\n const fromAuth = String(sources.authDisplayName ?? '').trim()\n if (fromAuth) return fromAuth\n return String(sources.email ?? '').trim()\n}\n\nexport function useUserName(): string {\n const firestore = useFirestore()\n const { data: user } = useUser()\n const uid = user?.uid\n const { data } = useFirestoreDoc<{\n firstName?: string\n lastName?: string\n }>(\n // Null ref until a uid exists, so a signed-out mount opens no listen at\n // all rather than one the rules refuse — same shape as the photo hook.\n () => (uid ? doc(firestore, 'users', uid) : null),\n [firestore, uid],\n )\n return resolveUserName({\n profileFirstName: data?.firstName,\n profileLastName: data?.lastName,\n authDisplayName: user?.displayName,\n email: user?.email,\n })\n}\n\nexport default useUserName\n"],"names":["doc","useFirestore","useUser","useFirestoreDoc","resolveUserName","sources","fromProfile","profileFirstName","profileLastName","map","part","String","trim","filter","Boolean","join","fromAuth","authDisplayName","email","useUserName","firestore","data","user","uid","firstName","lastName","displayName"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD,SAASA,GAAG,QAAQ,qBAAoB;AACxC,SAASC,YAAY,EAAEC,OAAO,QAAQ,kCAA8B;AACpE,SAASC,eAAe,QAAQ,yBAAqB;AAErD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkCC,GACD;;;;;;;CAOC,GACD,OAAO,SAASC,gBAAgBC,OAK/B;QAMyBA,0BAEVA;IAPd,MAAMC,cAAc;QAACD,QAAQE,gBAAgB;QAAEF,QAAQG,eAAe;KAAC,CACpEC,GAAG,CAAC,CAACC,OAASC,OAAOD,eAAAA,OAAQ,IAAIE,IAAI,IACrCC,MAAM,CAACC,SACPC,IAAI,CAAC;IACR,IAAIT,aAAa,OAAOA;IACxB,MAAMU,WAAWL,QAAON,2BAAAA,QAAQY,eAAe,YAAvBZ,2BAA2B,IAAIO,IAAI;IAC3D,IAAII,UAAU,OAAOA;IACrB,OAAOL,QAAON,iBAAAA,QAAQa,KAAK,YAAbb,iBAAiB,IAAIO,IAAI;AACzC;AAEA,OAAO,SAASO;IACd,MAAMC,YAAYnB;IAClB,MAAM,EAAEoB,MAAMC,IAAI,EAAE,GAAGpB;IACvB,MAAMqB,MAAMD,wBAAAA,KAAMC,GAAG;IACrB,MAAM,EAAEF,IAAI,EAAE,GAAGlB,gBAIf,wEAAwE;IACxE,uEAAuE;IACvE,IAAOoB,MAAMvB,IAAIoB,WAAW,SAASG,OAAO,MAC5C;QAACH;QAAWG;KAAI;IAElB,OAAOnB,gBAAgB;QACrBG,gBAAgB,EAAEc,wBAAAA,KAAMG,SAAS;QACjChB,eAAe,EAAEa,wBAAAA,KAAMI,QAAQ;QAC/BR,eAAe,EAAEK,wBAAAA,KAAMI,WAAW;QAClCR,KAAK,EAAEI,wBAAAA,KAAMJ,KAAK;IACpB;AACF;AAEA,eAAeC,YAAW"}
|