@brass-build/client 0.4.1 → 0.5.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/AGENTS.md +124 -45
- package/CHANGELOG.md +80 -4
- package/README.md +666 -179
- package/dist/api-fetch.d.ts +8 -0
- package/dist/api-fetch.d.ts.map +1 -1
- package/dist/api-fetch.js +35 -8
- package/dist/api-fetch.js.map +1 -1
- package/dist/batched-get.d.ts +11 -0
- package/dist/batched-get.d.ts.map +1 -0
- package/dist/batched-get.js +92 -0
- package/dist/batched-get.js.map +1 -0
- package/dist/call-service.d.ts +4 -0
- package/dist/call-service.d.ts.map +1 -1
- package/dist/call-service.js +66 -3
- package/dist/call-service.js.map +1 -1
- package/dist/client.d.ts +48 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +918 -115
- package/dist/client.js.map +1 -1
- package/dist/document-checkpoints.d.ts +25 -2
- package/dist/document-checkpoints.d.ts.map +1 -1
- package/dist/document-checkpoints.js +258 -61
- package/dist/document-checkpoints.js.map +1 -1
- package/dist/document-commands.d.ts.map +1 -1
- package/dist/document-commands.js +5 -3
- package/dist/document-commands.js.map +1 -1
- package/dist/document-contents.d.ts +10 -0
- package/dist/document-contents.d.ts.map +1 -1
- package/dist/document-contents.js +23 -1
- package/dist/document-contents.js.map +1 -1
- package/dist/document-set-subscription.d.ts +56 -0
- package/dist/document-set-subscription.d.ts.map +1 -0
- package/dist/document-set-subscription.js +240 -0
- package/dist/document-set-subscription.js.map +1 -0
- package/dist/document-signals.d.ts +6 -0
- package/dist/document-signals.d.ts.map +1 -1
- package/dist/document-signals.js +31 -1
- package/dist/document-signals.js.map +1 -1
- package/dist/document-streams.d.ts +36 -2
- package/dist/document-streams.d.ts.map +1 -1
- package/dist/document-streams.js +69 -18
- package/dist/document-streams.js.map +1 -1
- package/dist/document-thumbnail-capture.d.ts +12 -2
- package/dist/document-thumbnail-capture.d.ts.map +1 -1
- package/dist/document-thumbnail-capture.js +103 -27
- package/dist/document-thumbnail-capture.js.map +1 -1
- package/dist/documents-namespace.d.ts +92 -10
- package/dist/documents-namespace.d.ts.map +1 -1
- package/dist/documents-namespace.js +285 -39
- package/dist/documents-namespace.js.map +1 -1
- package/dist/documents.d.ts +173 -8
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +1778 -310
- package/dist/documents.js.map +1 -1
- package/dist/errors.d.ts +3 -3
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/fetch-retry.d.ts +1 -0
- package/dist/fetch-retry.d.ts.map +1 -1
- package/dist/fetch-retry.js +2 -0
- package/dist/fetch-retry.js.map +1 -1
- package/dist/file-choice.d.ts +2 -0
- package/dist/file-choice.d.ts.map +1 -0
- package/dist/file-choice.js +27 -0
- package/dist/file-choice.js.map +1 -0
- package/dist/guest-link.d.ts +18 -1
- package/dist/guest-link.d.ts.map +1 -1
- package/dist/guest-link.js +329 -1
- package/dist/guest-link.js.map +1 -1
- package/dist/handle-types.d.ts +67 -5
- package/dist/handle-types.d.ts.map +1 -1
- package/dist/handles.d.ts +15 -19
- package/dist/handles.d.ts.map +1 -1
- package/dist/handles.js +231 -76
- package/dist/handles.js.map +1 -1
- package/dist/hosted-ui.d.ts +61 -0
- package/dist/hosted-ui.d.ts.map +1 -1
- package/dist/hosted-ui.js +94 -42
- package/dist/hosted-ui.js.map +1 -1
- package/dist/import-contract.d.ts +74 -0
- package/dist/import-contract.d.ts.map +1 -0
- package/dist/import-contract.js +163 -0
- package/dist/import-contract.js.map +1 -0
- package/dist/import-guest.d.ts +9 -0
- package/dist/import-guest.d.ts.map +1 -0
- package/dist/import-guest.js +155 -0
- package/dist/import-guest.js.map +1 -0
- package/dist/import-report.d.ts +17 -0
- package/dist/import-report.d.ts.map +1 -0
- package/dist/import-report.js +7 -0
- package/dist/import-report.js.map +1 -0
- package/dist/importer-wait.d.ts +2 -0
- package/dist/importer-wait.d.ts.map +1 -0
- package/dist/importer-wait.js +5 -0
- package/dist/importer-wait.js.map +1 -0
- package/dist/importer.d.ts +18 -4
- package/dist/importer.d.ts.map +1 -1
- package/dist/importer.js +322 -105
- package/dist/importer.js.map +1 -1
- package/dist/in-development.d.ts +2 -0
- package/dist/in-development.d.ts.map +1 -0
- package/dist/in-development.js +10 -0
- package/dist/in-development.js.map +1 -0
- package/dist/index.d.ts +16 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +24 -8
- package/dist/index.js.map +1 -1
- package/dist/init-keys.d.ts +1 -1
- package/dist/init-keys.d.ts.map +1 -1
- package/dist/init-keys.js +6 -1
- package/dist/init-keys.js.map +1 -1
- package/dist/input-documents.d.ts +67 -0
- package/dist/input-documents.d.ts.map +1 -0
- package/dist/input-documents.js +693 -0
- package/dist/input-documents.js.map +1 -0
- package/dist/inputs-status.d.ts +80 -0
- package/dist/inputs-status.d.ts.map +1 -0
- package/dist/inputs-status.js +460 -0
- package/dist/inputs-status.js.map +1 -0
- package/dist/inputs.d.ts +2 -0
- package/dist/inputs.d.ts.map +1 -0
- package/dist/inputs.js +793 -0
- package/dist/inputs.js.map +1 -0
- package/dist/internal.d.ts +6 -1
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +4 -1
- package/dist/internal.js.map +1 -1
- package/dist/join-content.d.ts +33 -0
- package/dist/join-content.d.ts.map +1 -0
- package/dist/join-content.js +114 -0
- package/dist/join-content.js.map +1 -0
- package/dist/join-stamp.d.ts +3 -0
- package/dist/join-stamp.d.ts.map +1 -0
- package/dist/join-stamp.js +12 -0
- package/dist/join-stamp.js.map +1 -0
- package/dist/manifest-drift.d.ts +9 -0
- package/dist/manifest-drift.d.ts.map +1 -0
- package/dist/manifest-drift.js +46 -0
- package/dist/manifest-drift.js.map +1 -0
- package/dist/open-document.d.ts +109 -2
- package/dist/open-document.d.ts.map +1 -1
- package/dist/open-document.js +1822 -811
- package/dist/open-document.js.map +1 -1
- package/dist/page-restore.d.ts +12 -0
- package/dist/page-restore.d.ts.map +1 -0
- package/dist/page-restore.js +23 -0
- package/dist/page-restore.js.map +1 -0
- package/dist/placement.d.ts +13 -0
- package/dist/placement.d.ts.map +1 -0
- package/dist/placement.js +34 -0
- package/dist/placement.js.map +1 -0
- package/dist/presence.d.ts +1 -0
- package/dist/presence.d.ts.map +1 -1
- package/dist/presence.js +16 -4
- package/dist/presence.js.map +1 -1
- package/dist/read-only-document.d.ts +87 -0
- package/dist/read-only-document.d.ts.map +1 -0
- package/dist/read-only-document.js +4 -0
- package/dist/read-only-document.js.map +1 -0
- package/dist/record-paging.d.ts +63 -6
- package/dist/record-paging.d.ts.map +1 -1
- package/dist/record-paging.js +122 -29
- package/dist/record-paging.js.map +1 -1
- package/dist/references.d.ts +40 -0
- package/dist/references.d.ts.map +1 -0
- package/dist/references.js +135 -0
- package/dist/references.js.map +1 -0
- package/dist/request-lane.d.ts +5 -0
- package/dist/request-lane.d.ts.map +1 -0
- package/dist/request-lane.js +48 -0
- package/dist/request-lane.js.map +1 -0
- package/dist/sealed-arrow.d.ts +170 -0
- package/dist/sealed-arrow.d.ts.map +1 -0
- package/dist/sealed-arrow.js +769 -0
- package/dist/sealed-arrow.js.map +1 -0
- package/dist/sealed-cache.d.ts +21 -0
- package/dist/sealed-cache.d.ts.map +1 -0
- package/dist/sealed-cache.js +247 -0
- package/dist/sealed-cache.js.map +1 -0
- package/dist/sealed-encode.d.ts +87 -0
- package/dist/sealed-encode.d.ts.map +1 -0
- package/dist/sealed-encode.js +876 -0
- package/dist/sealed-encode.js.map +1 -0
- package/dist/sealed-handles.d.ts +45 -0
- package/dist/sealed-handles.d.ts.map +1 -0
- package/dist/sealed-handles.js +408 -0
- package/dist/sealed-handles.js.map +1 -0
- package/dist/sealed-store.d.ts +242 -0
- package/dist/sealed-store.d.ts.map +1 -0
- package/dist/sealed-store.js +1155 -0
- package/dist/sealed-store.js.map +1 -0
- package/dist/serializer.d.ts +6 -15
- package/dist/serializer.d.ts.map +1 -1
- package/dist/session-toolbar.d.ts +20 -5
- package/dist/session-toolbar.d.ts.map +1 -1
- package/dist/session-toolbar.js +419 -67
- package/dist/session-toolbar.js.map +1 -1
- package/dist/session.d.ts +38 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +562 -79
- package/dist/session.js.map +1 -1
- package/dist/sha256.d.ts +3 -0
- package/dist/sha256.d.ts.map +1 -0
- package/dist/sha256.js +74 -0
- package/dist/sha256.js.map +1 -0
- package/dist/stable-handles.d.ts +19 -0
- package/dist/stable-handles.d.ts.map +1 -0
- package/dist/stable-handles.js +193 -0
- package/dist/stable-handles.js.map +1 -0
- package/dist/staged-seal.d.ts +2 -0
- package/dist/staged-seal.d.ts.map +1 -0
- package/dist/staged-seal.js +76 -0
- package/dist/staged-seal.js.map +1 -0
- package/dist/subscriber-delivery.d.ts +8 -0
- package/dist/subscriber-delivery.d.ts.map +1 -0
- package/dist/subscriber-delivery.js +31 -0
- package/dist/subscriber-delivery.js.map +1 -0
- package/dist/tail-op-import.d.ts +10 -0
- package/dist/tail-op-import.d.ts.map +1 -0
- package/dist/tail-op-import.js +33 -0
- package/dist/tail-op-import.js.map +1 -0
- package/dist/topic-lifecycle.d.ts +6 -0
- package/dist/topic-lifecycle.d.ts.map +1 -1
- package/dist/topic-lifecycle.js +32 -2
- package/dist/topic-lifecycle.js.map +1 -1
- package/dist/trial-replace-dialog.d.ts +10 -0
- package/dist/trial-replace-dialog.d.ts.map +1 -0
- package/dist/trial-replace-dialog.js +153 -0
- package/dist/trial-replace-dialog.js.map +1 -0
- package/dist/types.d.ts +8 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/undo.d.ts +60 -5
- package/dist/undo.d.ts.map +1 -1
- package/dist/undo.js +344 -75
- package/dist/undo.js.map +1 -1
- package/dist/version-bar.d.ts +1 -0
- package/dist/version-bar.d.ts.map +1 -1
- package/dist/version-bar.js +2 -1
- package/dist/version-bar.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/dist/ws-client.d.ts +21 -0
- package/dist/ws-client.d.ts.map +1 -1
- package/dist/ws-client.js +192 -8
- package/dist/ws-client.js.map +1 -1
- package/dist/ws-frame-limit.d.ts +1 -0
- package/dist/ws-frame-limit.d.ts.map +1 -1
- package/dist/ws-frame-limit.js.map +1 -1
- package/docs/app-manifest.md +121 -17
- package/docs/custom-chrome.md +24 -16
- package/docs/hosted-ui-advanced.md +38 -6
- package/docs/imported-documents.md +104 -51
- package/docs/presence.md +16 -4
- package/docs/schemas.md +8 -5
- package/docs/services.md +6 -6
- package/docs/streams.md +139 -150
- package/docs/text-fields.md +26 -19
- package/package.json +5 -2
- package/src/api-fetch.ts +53 -3
- package/src/auth-flow.ts +1 -1
- package/src/batched-get.ts +117 -0
- package/src/call-service.ts +78 -3
- package/src/client.ts +1022 -112
- package/src/document-checkpoints.ts +307 -65
- package/src/document-commands.ts +5 -5
- package/src/document-contents.ts +27 -1
- package/src/document-set-subscription.ts +274 -0
- package/src/document-signals.ts +47 -5
- package/src/document-streams.ts +85 -16
- package/src/document-thumbnail-capture.ts +118 -32
- package/src/documents-namespace.ts +539 -84
- package/src/documents.ts +2017 -348
- package/src/errors.ts +17 -6
- package/src/fetch-retry.ts +5 -0
- package/src/file-choice.ts +26 -0
- package/src/guest-link.ts +362 -1
- package/src/handle-types.ts +73 -47
- package/src/handles.ts +253 -83
- package/src/hosted-ui.ts +211 -50
- package/src/import-contract.ts +227 -0
- package/src/import-guest.ts +193 -0
- package/src/import-report.ts +24 -0
- package/src/importer-wait.ts +4 -0
- package/src/importer.ts +386 -131
- package/src/in-development.ts +10 -0
- package/src/index.ts +76 -16
- package/src/init-keys.ts +6 -1
- package/src/input-documents.ts +819 -0
- package/src/inputs-status.ts +598 -0
- package/src/inputs.ts +1090 -0
- package/src/internal.ts +7 -0
- package/src/join-content.ts +135 -0
- package/src/join-stamp.ts +12 -0
- package/src/manifest-drift.ts +54 -0
- package/src/open-document.ts +2346 -930
- package/src/page-restore.ts +38 -0
- package/src/placement.ts +54 -0
- package/src/presence.ts +21 -5
- package/src/read-only-document.ts +133 -0
- package/src/record-paging.ts +147 -25
- package/src/references.ts +207 -0
- package/src/request-lane.ts +59 -0
- package/src/sealed-arrow.ts +843 -0
- package/src/sealed-cache.ts +267 -0
- package/src/sealed-encode.ts +1015 -0
- package/src/sealed-handles.ts +486 -0
- package/src/sealed-store.ts +1330 -0
- package/src/serializer.ts +8 -20
- package/src/session-toolbar.ts +463 -72
- package/src/session.ts +740 -99
- package/src/sha256.ts +65 -0
- package/src/stable-handles.ts +235 -0
- package/src/staged-seal.ts +90 -0
- package/src/subscriber-delivery.ts +37 -0
- package/src/tail-op-import.ts +30 -0
- package/src/topic-lifecycle.ts +38 -2
- package/src/trial-replace-dialog.ts +163 -0
- package/src/types.ts +6 -6
- package/src/undo.ts +386 -73
- package/src/version-bar.ts +5 -1
- package/src/version.ts +1 -1
- package/src/ws-client.ts +208 -16
- package/src/ws-frame-limit.ts +1 -0
package/src/session.ts
CHANGED
|
@@ -14,14 +14,18 @@ import type {
|
|
|
14
14
|
CommandSurface,
|
|
15
15
|
Document,
|
|
16
16
|
DocumentOptions,
|
|
17
|
+
ReopenMode,
|
|
17
18
|
} from './documents.js';
|
|
18
19
|
import type {
|
|
19
20
|
CreateDocumentArgs,
|
|
20
21
|
CreateDocumentOptions,
|
|
21
22
|
CreateDocumentResult,
|
|
23
|
+
MintedAnonymousDocument,
|
|
22
24
|
OpenDocumentByIdOptions,
|
|
23
25
|
OpenedDocument,
|
|
24
26
|
} from './open-document.js';
|
|
27
|
+
import type { ReadOnlyDocument } from './read-only-document.js';
|
|
28
|
+
import { placeThroughHostedSurfaces } from './placement.js';
|
|
25
29
|
import type {
|
|
26
30
|
CreateDestinationResult,
|
|
27
31
|
DocumentHistoryResult,
|
|
@@ -34,6 +38,7 @@ import type { UrlSelection } from './doc-url-sync.js';
|
|
|
34
38
|
import type { AuthStatus } from './types.js';
|
|
35
39
|
import { BrassError } from './errors.js';
|
|
36
40
|
import { makeLiveCommand } from './document-commands.js';
|
|
41
|
+
import { appCallbackError } from './subscriber-delivery.js';
|
|
37
42
|
|
|
38
43
|
// A command in `session.commands`. The SDK owns each command's `run` /
|
|
39
44
|
// `enabled` / `running` plus a `surface` placement hint; the app places each
|
|
@@ -49,7 +54,7 @@ export type {
|
|
|
49
54
|
// Coarse lifecycle state: 'unknown' (auth not yet determined), 'signed-out',
|
|
50
55
|
// 'empty' (signed in, no document), 'loading' (open in flight), 'ready',
|
|
51
56
|
// 'error' (a user-initiated open terminally failed with nothing to fall back
|
|
52
|
-
// to). 'unknown' is distinct from 'signed-out' so a status-gated app renders a
|
|
57
|
+
// to, or `Brass.init` rejected its configuration). 'unknown' is distinct from 'signed-out' so a status-gated app renders a
|
|
53
58
|
// neutral state during boot instead of flashing the sign-in surface at a user
|
|
54
59
|
// who turns out to be signed in. 'error' is distinct from 'empty' so a
|
|
55
60
|
// document the user navigated to but cannot open (deleted, or an access the
|
|
@@ -68,6 +73,10 @@ export interface SessionOpenOptions {
|
|
|
68
73
|
branch?: string;
|
|
69
74
|
// Open a historical version read-only (`document.readOnly` is true).
|
|
70
75
|
version?: number;
|
|
76
|
+
/** Open this document read-only (`true`) or for editing (`false`), whatever
|
|
77
|
+
* the mode set at `Brass.init` (`open: { readOnly }`). Unset, that mode
|
|
78
|
+
* decides. */
|
|
79
|
+
readOnly?: boolean;
|
|
71
80
|
// Forwarded to the Document materialization (e.g. the undo merge
|
|
72
81
|
// interval). Matches `documentOptions` on `OpenDocumentByIdOptions` /
|
|
73
82
|
// `CreateDocumentOptions`.
|
|
@@ -92,7 +101,8 @@ export interface DocumentSession {
|
|
|
92
101
|
// evolve the wording without any app change); an app supplies its own
|
|
93
102
|
// recovery affordances around it and does not author the copy. `.status`
|
|
94
103
|
// is there for logging or an override the app absolutely needs. Cleared on
|
|
95
|
-
// the next open / create / close.
|
|
104
|
+
// the next open / create / close. Also set, with no `status`, when
|
|
105
|
+
// `Brass.init` rejected its configuration (a missing app id).
|
|
96
106
|
readonly error: BrassError | null;
|
|
97
107
|
// While `status` is `'loading'`, a progress message for the open in flight
|
|
98
108
|
// (e.g. `Downloading... 62%`), or null. Populated as a presigned snapshot
|
|
@@ -119,15 +129,19 @@ export interface DocumentSession {
|
|
|
119
129
|
// any, else the empty state. Safe before sign-in (resolves null). Also runs
|
|
120
130
|
// automatically on first sign-in.
|
|
121
131
|
start(): Promise<Document | null>;
|
|
132
|
+
// With no id, open the hosted picker and make the picked document active;
|
|
133
|
+
// null on dismiss. Signed out, ask for a file instead and open the document
|
|
134
|
+
// made from it with no account, as `create` does for an empty one.
|
|
135
|
+
open(): Promise<Document | null>;
|
|
122
136
|
// Open a document by id and make it active.
|
|
137
|
+
open(docId: string, options: SessionOpenOptions & { readOnly: true }): Promise<ReadOnlyDocument>;
|
|
123
138
|
open(docId: string, options?: SessionOpenOptions): Promise<Document>;
|
|
124
139
|
// Create a document and make it active. Create-in-place: with no explicit
|
|
125
140
|
// destination it lands as a sibling of the active one. A cold create (no
|
|
126
141
|
// active doc) in a single-org caller lands loose; a multi-org caller is
|
|
127
142
|
// asked via the hosted destination dialog, resolving null on dismiss.
|
|
128
143
|
create(options?: CreateDocumentOptions): Promise<Document | null>;
|
|
129
|
-
|
|
130
|
-
// dismiss.
|
|
144
|
+
/** @deprecated Use `open()` with no id. */
|
|
131
145
|
pick(): Promise<Document | null>;
|
|
132
146
|
// Close the active document and return to the empty state.
|
|
133
147
|
close(): void;
|
|
@@ -160,10 +174,19 @@ export interface DocumentSession {
|
|
|
160
174
|
* delete request failed, so a later remote delete of the same doc is not
|
|
161
175
|
* misread as local. */
|
|
162
176
|
_clearLocalDelete(docId: string): void;
|
|
177
|
+
/** @internal Open what the hosted history and branch surfaces switch to
|
|
178
|
+
* from the bound document, in the mode that document was opened in. */
|
|
179
|
+
_openSibling(docId: string, options?: SessionOpenOptions): Promise<Document>;
|
|
163
180
|
}
|
|
164
181
|
|
|
165
182
|
// Dependency seam. The session is a pure coordinator over the public SDK
|
|
166
183
|
// surface; injecting these keeps it unit-testable and breaks the import cycle.
|
|
184
|
+
export type ClaimedLinkTarget = { docId: string } | 'sign-in' | null;
|
|
185
|
+
|
|
186
|
+
// Thrown through a guest open whose link leads its claimant to sign in: the
|
|
187
|
+
// open ends with no document and no error, on the signed-out surface.
|
|
188
|
+
class SignInToFollowLink extends Error {}
|
|
189
|
+
|
|
167
190
|
export interface DocumentSessionDeps {
|
|
168
191
|
isSignedIn(): boolean;
|
|
169
192
|
// The tri-state auth lifecycle, so `status()` reports `'unknown'` during
|
|
@@ -226,16 +249,69 @@ export interface DocumentSessionDeps {
|
|
|
226
249
|
lastSelectedDocument(): SelectDocumentResult | null;
|
|
227
250
|
documents: Pick<DocumentsNamespace, 'get' | 'setThumbnail'>;
|
|
228
251
|
reportError(error: Error): void;
|
|
252
|
+
// The configuration failure that stopped `Brass.init`, or null. Nothing can
|
|
253
|
+
// sign in or open while it holds, so it is the session's error.
|
|
254
|
+
initFailure?(): BrassError | null;
|
|
229
255
|
// Anonymous "anyone with the link" boot. When the page was opened via a
|
|
230
256
|
// public-link token (`?brass_link=`), `guestLinkToken` returns it and the
|
|
231
257
|
// session boots by opening that document read-only through
|
|
232
258
|
// `openDocumentByLink` instead of the signed-in URL / last-selected
|
|
233
259
|
// restore. Absent (returns null) in the ordinary signed-in case.
|
|
234
260
|
guestLinkToken?(): string | null;
|
|
261
|
+
// Whether the app set `open: { readOnly: true }` at init, which opens every
|
|
262
|
+
// document the session opens (other than one it creates) read-only.
|
|
263
|
+
openReadOnly?(): boolean;
|
|
235
264
|
openDocumentByLink?(
|
|
236
265
|
token: string,
|
|
237
266
|
options?: OpenDocumentByIdOptions,
|
|
238
267
|
): Promise<OpenedDocument>;
|
|
268
|
+
// A create while signed out mints a document with no account
|
|
269
|
+
// (`POST /anonymous-documents`); `enterGuestMode` then makes its link
|
|
270
|
+
// token the page's credential (`guestLinkToken` returns it, the URL
|
|
271
|
+
// carries it) so the session opens it through `openDocumentByLink`.
|
|
272
|
+
mintAnonymousDocument?(args: {
|
|
273
|
+
name?: string;
|
|
274
|
+
}): Promise<MintedAnonymousDocument>;
|
|
275
|
+
// A pick while signed out asks for a file instead (`chooseAnonymousFile`,
|
|
276
|
+
// the browser's dialog filtered to what the app can start from; null on
|
|
277
|
+
// dismiss) and mints the document from it.
|
|
278
|
+
chooseAnonymousFile?(): Promise<File | null>;
|
|
279
|
+
mintAnonymousDocumentFromFile?(args: {
|
|
280
|
+
file: File;
|
|
281
|
+
onUploadProgress?: (loaded: number, total: number) => void;
|
|
282
|
+
}): Promise<MintedAnonymousDocument>;
|
|
283
|
+
enterGuestMode?(token: string): void;
|
|
284
|
+
// Whether the app may be used without an account, read once the page is
|
|
285
|
+
// signed out: the refusal the server answers for an app that is not
|
|
286
|
+
// public, or null. New and Open are offered until it answers.
|
|
287
|
+
signedOutUseRefusal?(): Promise<BrassError | null>;
|
|
288
|
+
// Whether this app offers New: false when its published manifest says
|
|
289
|
+
// `"access": { "create": false }`.
|
|
290
|
+
appOffersNew?(): Promise<boolean>;
|
|
291
|
+
// The last document created without an account on this app, as the
|
|
292
|
+
// client stashed it when a guest open resolved one (`readUnsavedWork`):
|
|
293
|
+
// null once expired or cleared. `clearUnsavedWork` forgets it after the
|
|
294
|
+
// link is refused for good, and `leaveGuestMode` takes the token back
|
|
295
|
+
// out of the page's credential and URL after such a refusal, so the
|
|
296
|
+
// session reports signed out again rather than a guest with nothing open.
|
|
297
|
+
unsavedWork?(): { token: string; expiresAt: Date } | null;
|
|
298
|
+
clearUnsavedWork?(): void;
|
|
299
|
+
leaveGuestMode?(): void;
|
|
300
|
+
// A link the platform refused with 410 may be one this person's own claim
|
|
301
|
+
// revoked. `followClaimedLink` takes the page out of guest mode for the
|
|
302
|
+
// account that claimed it, signed in, and resolves the document's id; in the
|
|
303
|
+
// browser that claimed it with no session, signed out and `'sign-in'`; and
|
|
304
|
+
// null for anyone else, leaving the page the guest it was.
|
|
305
|
+
followClaimedLink?(token: string): Promise<ClaimedLinkTarget>;
|
|
306
|
+
// New and Upload ask before replacing a document made without an account
|
|
307
|
+
// that this browser has edited (`markTrialEdited` records an edit,
|
|
308
|
+
// `trialEdited` reads it back across reloads). `confirmReplaceTrial` asks,
|
|
309
|
+
// and its `'sign-in'` answer starts `signIn`, which keeps the document by
|
|
310
|
+
// claiming it into the account.
|
|
311
|
+
markTrialEdited?(token: string, expiresAt: Date): void;
|
|
312
|
+
trialEdited?(token: string): boolean;
|
|
313
|
+
confirmReplaceTrial?(expiresAt: Date): Promise<'sign-in' | 'leave' | 'cancel'>;
|
|
314
|
+
signIn?(): void;
|
|
239
315
|
}
|
|
240
316
|
|
|
241
317
|
// Built-in orphan command labels (no active doc needed). Sentence case, ASCII
|
|
@@ -245,6 +321,9 @@ export interface DocumentSessionDeps {
|
|
|
245
321
|
const SESSION_LABELS = {
|
|
246
322
|
new: 'New',
|
|
247
323
|
open: 'Open...',
|
|
324
|
+
// Without an account there is nothing to browse, so Open takes a file from
|
|
325
|
+
// the reader's own machine.
|
|
326
|
+
upload: 'Upload...',
|
|
248
327
|
} as const;
|
|
249
328
|
|
|
250
329
|
function toError(err: unknown): Error {
|
|
@@ -254,15 +333,19 @@ function toError(err: unknown): Error {
|
|
|
254
333
|
// Fallback copy for a failed open with no server message to render (a
|
|
255
334
|
// network drop, no HTTP response). The HTTP case carries the server's own
|
|
256
335
|
// message instead, so the platform evolves the wording without an app change.
|
|
257
|
-
const OPEN_FAILED_MESSAGE = 'This
|
|
336
|
+
const OPEN_FAILED_MESSAGE = 'This could not be opened';
|
|
337
|
+
// The session holds a live copy, so a frozen one is opened beside it.
|
|
338
|
+
const FROZEN_IN_SESSION =
|
|
339
|
+
'The session holds a live copy of its document. Open a frozen copy with Brass.openDocumentById';
|
|
340
|
+
const PREPARING_MESSAGE = 'Preparing for editing...';
|
|
258
341
|
|
|
259
342
|
// Copy parked on `session.error` when the open document is evicted remotely
|
|
260
343
|
// (a `doc_gone` the local user did not initiate). Matches the wording a
|
|
261
|
-
// post-eviction op frame degrades to ("
|
|
344
|
+
// post-eviction op frame degrades to ("this is no longer available"),
|
|
262
345
|
// so a delete observed live and a fresh visit to the same deleted id read the
|
|
263
346
|
// same. A reload re-opens the id, hits the server 404, and swaps in the
|
|
264
347
|
// server-authored copy.
|
|
265
|
-
const DOCUMENT_GONE_MESSAGE = 'This
|
|
348
|
+
const DOCUMENT_GONE_MESSAGE = 'This is no longer available';
|
|
266
349
|
|
|
267
350
|
// The error parked on `session.error` for a failed open, shaped so its
|
|
268
351
|
// `.message` is always presentable copy an app can render verbatim. An HTTP
|
|
@@ -309,6 +392,9 @@ export function createDocumentSession(
|
|
|
309
392
|
// changes, so a per-commit `doc.subscribe` that doesn't move a command's
|
|
310
393
|
// enabled state stays quiet.
|
|
311
394
|
let lastSignature: string | null = null;
|
|
395
|
+
// Counts the documents bound, so a new copy of the same id (a reopen for
|
|
396
|
+
// editing, a reload) changes the signature and reaches subscribers.
|
|
397
|
+
let bindCount = 0;
|
|
312
398
|
// Outstanding `whenReady()` promises, settled in `emit()`: resolve once a
|
|
313
399
|
// document binds, reject if the user signs out before one ever does.
|
|
314
400
|
const whenReadyPending: Array<{
|
|
@@ -345,15 +431,73 @@ export function createDocumentSession(
|
|
|
345
431
|
// misread as local.
|
|
346
432
|
const localDeletes = new Set<string>();
|
|
347
433
|
|
|
434
|
+
// Set for the length of an open started without an account (see `status`).
|
|
435
|
+
let accountFreeOpen = false;
|
|
436
|
+
|
|
437
|
+
// Documents the session opened read-only by request, as opposed to ones a
|
|
438
|
+
// reader's access made read-only. A reopen of one (a move, a reload) keeps
|
|
439
|
+
// that mode.
|
|
440
|
+
const readOnlyOpens = new WeakSet<Document>();
|
|
441
|
+
|
|
442
|
+
// The read intent of one open: the caller's ask where it made one, else the
|
|
443
|
+
// mode the app set at init.
|
|
444
|
+
function inMode<T extends OpenDocumentByIdOptions>(
|
|
445
|
+
options: T,
|
|
446
|
+
asked: boolean | undefined,
|
|
447
|
+
): T {
|
|
448
|
+
const readOnly = asked ?? (deps.openReadOnly?.() === true ? true : undefined);
|
|
449
|
+
return readOnly === undefined ? options : { ...options, readOnly };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Opens that asked to read, keyed by the open's own promise, so `activate`
|
|
453
|
+
// records the document it binds from one in `readOnlyOpens`.
|
|
454
|
+
const readOnlyAsks = new WeakSet<Promise<OpenedDocument>>();
|
|
455
|
+
|
|
456
|
+
function remember(
|
|
457
|
+
opened: Promise<OpenedDocument>,
|
|
458
|
+
options: OpenDocumentByIdOptions,
|
|
459
|
+
): Promise<OpenedDocument> {
|
|
460
|
+
if (options.readOnly ?? options.documentOptions?.readOnly === true) {
|
|
461
|
+
readOnlyAsks.add(opened);
|
|
462
|
+
}
|
|
463
|
+
return opened;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function openById(
|
|
467
|
+
docId: string,
|
|
468
|
+
options: OpenDocumentByIdOptions,
|
|
469
|
+
consent?: 'offer' | 'skip',
|
|
470
|
+
): Promise<OpenedDocument> {
|
|
471
|
+
const opened =
|
|
472
|
+
consent === undefined
|
|
473
|
+
? deps.openDocumentById(docId, options)
|
|
474
|
+
: deps.openDocumentById(docId, options, consent);
|
|
475
|
+
return remember(opened, options);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function openByLink(
|
|
479
|
+
openGuest: NonNullable<DocumentSessionDeps['openDocumentByLink']>,
|
|
480
|
+
token: string,
|
|
481
|
+
options: OpenDocumentByIdOptions,
|
|
482
|
+
): Promise<OpenedDocument> {
|
|
483
|
+
return remember(openGuest(token, options), options);
|
|
484
|
+
}
|
|
485
|
+
|
|
348
486
|
function status(): SessionStatus {
|
|
349
487
|
// 'unknown' before auth settles; the onUserChange handler re-fires
|
|
350
488
|
// (start() / emit()) and flips this to its resolved value.
|
|
351
489
|
if (deps.authStatus() === 'unknown') return 'unknown';
|
|
490
|
+
if (deps.initFailure?.()) return 'error';
|
|
352
491
|
// A guest (opened via `?brass_link=`) is not signed in but still has a
|
|
353
492
|
// read-only document to load, so it never reports 'signed-out' (which
|
|
354
493
|
// would render the app's sign-in surface instead of the document).
|
|
355
494
|
const isGuest = deps.guestLinkToken?.() != null;
|
|
356
|
-
|
|
495
|
+
// An open started without an account loads before there is a link to be
|
|
496
|
+
// a guest on (a pick sends its file first), and the visitor is watching
|
|
497
|
+
// that load, not the sign-in surface.
|
|
498
|
+
if (!deps.isSignedIn() && !isGuest && !(loading && accountFreeOpen)) {
|
|
499
|
+
return 'signed-out';
|
|
500
|
+
}
|
|
357
501
|
if (loading) return 'loading';
|
|
358
502
|
if (document) return 'ready';
|
|
359
503
|
if (openError) return 'error';
|
|
@@ -374,15 +518,26 @@ export function createDocumentSession(
|
|
|
374
518
|
const load = loading ? `${loadingMessage ?? ''}:${loadingProgress ?? ''}` : '';
|
|
375
519
|
// `openError` message joins the signature so a switch from one failed open
|
|
376
520
|
// to a different one (same 'error' status, no bound doc) still re-fires.
|
|
377
|
-
const
|
|
521
|
+
const shown = openError ?? deps.initFailure?.() ?? null;
|
|
522
|
+
const err = shown ? `${shown.status ?? ''}:${shown.message}` : '';
|
|
378
523
|
// The previewed version and the browsing flag are their own terms because
|
|
379
524
|
// nothing else in this signature follows them: a preview -> preview step
|
|
380
525
|
// holds the id, the name, the status and every command's enabled state
|
|
381
526
|
// (edit affordances are already disabled by the first checkout), so
|
|
382
527
|
// without these the step is a silent emit and the chrome rendering the
|
|
383
528
|
// preview keeps showing the version the reader stepped away from.
|
|
384
|
-
const ver = `${document?.
|
|
385
|
-
|
|
529
|
+
const ver = `${document?.previewVersion ?? ''}:${document?.versionBrowsing ? 1 : 0}`;
|
|
530
|
+
// The expiry moves once, to null, when the document is claimed; the
|
|
531
|
+
// chrome naming it has to hear that.
|
|
532
|
+
const expiry = document?.expiresAt?.getTime() ?? '';
|
|
533
|
+
return `${status()}|${bindCount}|${document?.id ?? ''}|${documentName ?? ''}|${load}|${err}|${ver}|${expiry}|${cmds}`;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// A listener that throws usually leaves the app showing nothing, so the
|
|
537
|
+
// app's `onError` hears about it as well as the console.
|
|
538
|
+
function reportListenerThrow(err: unknown): void {
|
|
539
|
+
console.error('[Brass] session listener threw:', err);
|
|
540
|
+
deps.reportError(appCallbackError(err));
|
|
386
541
|
}
|
|
387
542
|
|
|
388
543
|
function emit(): void {
|
|
@@ -401,7 +556,7 @@ export function createDocumentSession(
|
|
|
401
556
|
listener(session);
|
|
402
557
|
} catch (err) {
|
|
403
558
|
// A throwing listener must not strand the others.
|
|
404
|
-
|
|
559
|
+
reportListenerThrow(err);
|
|
405
560
|
}
|
|
406
561
|
}
|
|
407
562
|
}
|
|
@@ -419,11 +574,13 @@ export function createDocumentSession(
|
|
|
419
574
|
);
|
|
420
575
|
const pending = whenReadyPending.splice(0, whenReadyPending.length);
|
|
421
576
|
for (const { reject } of pending) reject(error);
|
|
422
|
-
} else if (current === 'error' &&
|
|
423
|
-
// A user-initiated open failed terminally
|
|
424
|
-
// awaiter forever. Same error object the
|
|
577
|
+
} else if (current === 'error' && session.error) {
|
|
578
|
+
// A user-initiated open failed terminally, or init could not start;
|
|
579
|
+
// reject rather than hang the awaiter forever. Same error object the
|
|
580
|
+
// session surfaces on `.error`.
|
|
581
|
+
const failure = session.error;
|
|
425
582
|
const pending = whenReadyPending.splice(0, whenReadyPending.length);
|
|
426
|
-
for (const { reject } of pending) reject(
|
|
583
|
+
for (const { reject } of pending) reject(failure);
|
|
427
584
|
}
|
|
428
585
|
// No branch for 'empty': it is both the idle pre-open state (an awaiter
|
|
429
586
|
// legitimately waits for a future open) and the post-close state. A close
|
|
@@ -447,7 +604,7 @@ export function createDocumentSession(
|
|
|
447
604
|
// active doc), with a session `emit` on each running flip.
|
|
448
605
|
function buildOrphanCommand(
|
|
449
606
|
id: string,
|
|
450
|
-
label: string,
|
|
607
|
+
label: string | (() => string),
|
|
451
608
|
group: CommandGroup,
|
|
452
609
|
surface: CommandSurface,
|
|
453
610
|
isEnabled: () => boolean,
|
|
@@ -463,22 +620,81 @@ export function createDocumentSession(
|
|
|
463
620
|
);
|
|
464
621
|
}
|
|
465
622
|
|
|
623
|
+
// New and Open start a document without an account where the app can mint
|
|
624
|
+
// one, so a visitor's own buttons and the menu run the same commands.
|
|
625
|
+
// A failed init leaves the page signed out with nothing either command
|
|
626
|
+
// could reach, so neither is offered.
|
|
627
|
+
const withoutAccount = (): boolean => {
|
|
628
|
+
if (deps.initFailure?.()) return false;
|
|
629
|
+
const status = deps.authStatus();
|
|
630
|
+
return status === 'signed-out' || status === 'guest';
|
|
631
|
+
};
|
|
632
|
+
// The server refuses every account-free route for an app that does not
|
|
633
|
+
// offer signed-out use, so New and Open are withdrawn once it says so
|
|
634
|
+
// rather than offered only to show that refusal. Read lazily, from the
|
|
635
|
+
// first check made while signed out, and settled for the page.
|
|
636
|
+
let signedOutRefusal: BrassError | null = null;
|
|
637
|
+
let signedOutProbe: Promise<void> | null = null;
|
|
638
|
+
const refusedWithoutAccount = (): boolean => {
|
|
639
|
+
if (signedOutProbe === null && deps.signedOutUseRefusal) {
|
|
640
|
+
const read = deps.signedOutUseRefusal;
|
|
641
|
+
signedOutProbe = (async () => {
|
|
642
|
+
try {
|
|
643
|
+
signedOutRefusal = await read();
|
|
644
|
+
} catch {
|
|
645
|
+
signedOutRefusal = null;
|
|
646
|
+
}
|
|
647
|
+
if (signedOutRefusal !== null) emit();
|
|
648
|
+
})();
|
|
649
|
+
}
|
|
650
|
+
return signedOutRefusal !== null;
|
|
651
|
+
};
|
|
652
|
+
const canStartWithoutAccount = (): boolean =>
|
|
653
|
+
withoutAccount() &&
|
|
654
|
+
!refusedWithoutAccount() &&
|
|
655
|
+
deps.mintAnonymousDocument !== undefined &&
|
|
656
|
+
deps.enterGuestMode !== undefined &&
|
|
657
|
+
deps.openDocumentByLink !== undefined;
|
|
658
|
+
const canPickWithoutAccount = (): boolean =>
|
|
659
|
+
canStartWithoutAccount() &&
|
|
660
|
+
deps.chooseAnonymousFile !== undefined &&
|
|
661
|
+
deps.mintAnonymousDocumentFromFile !== undefined;
|
|
662
|
+
|
|
663
|
+
// An app that creates no documents offers no New. Read lazily, from the
|
|
664
|
+
// first check, and settled for the page; until it answers, New is offered.
|
|
665
|
+
let offersNew = true;
|
|
666
|
+
let offersNewProbe: Promise<void> | null = null;
|
|
667
|
+
const appOffersNew = (): boolean => {
|
|
668
|
+
if (offersNewProbe === null && deps.appOffersNew) {
|
|
669
|
+
const read = deps.appOffersNew;
|
|
670
|
+
offersNewProbe = (async () => {
|
|
671
|
+
try {
|
|
672
|
+
offersNew = await read();
|
|
673
|
+
} catch {
|
|
674
|
+
offersNew = true;
|
|
675
|
+
}
|
|
676
|
+
if (!offersNew) emit();
|
|
677
|
+
})();
|
|
678
|
+
}
|
|
679
|
+
return offersNew;
|
|
680
|
+
};
|
|
681
|
+
|
|
466
682
|
const cmdNew = buildOrphanCommand(
|
|
467
683
|
'new',
|
|
468
684
|
SESSION_LABELS.new,
|
|
469
685
|
'session',
|
|
470
686
|
'menu',
|
|
471
|
-
deps.isSignedIn,
|
|
687
|
+
() => appOffersNew() && (deps.isSignedIn() || canStartWithoutAccount()),
|
|
472
688
|
async () => {
|
|
473
689
|
await create();
|
|
474
690
|
},
|
|
475
691
|
);
|
|
476
692
|
const cmdOpen = buildOrphanCommand(
|
|
477
693
|
'open',
|
|
478
|
-
SESSION_LABELS.open,
|
|
694
|
+
() => (withoutAccount() ? SESSION_LABELS.upload : SESSION_LABELS.open),
|
|
479
695
|
'session',
|
|
480
696
|
'menu',
|
|
481
|
-
deps.isSignedIn,
|
|
697
|
+
() => deps.isSignedIn() || canPickWithoutAccount(),
|
|
482
698
|
async () => {
|
|
483
699
|
await pick();
|
|
484
700
|
},
|
|
@@ -521,6 +737,7 @@ export function createDocumentSession(
|
|
|
521
737
|
sessionCanvasUnregister = null;
|
|
522
738
|
}
|
|
523
739
|
const previous = document;
|
|
740
|
+
if (next !== previous) bindCount += 1;
|
|
524
741
|
document = next;
|
|
525
742
|
documentName = name;
|
|
526
743
|
if (next === null) {
|
|
@@ -533,6 +750,19 @@ export function createDocumentSession(
|
|
|
533
750
|
// A close failure (already-closed, freed WASM) is non-fatal.
|
|
534
751
|
}
|
|
535
752
|
}
|
|
753
|
+
if (next && next !== previous) {
|
|
754
|
+
// `reopen` on the bound copy goes through the session, so the new copy
|
|
755
|
+
// binds in its place. A copy the session no longer holds reopens the
|
|
756
|
+
// way the layer that opened it would.
|
|
757
|
+
const outside = next._reopenerFn;
|
|
758
|
+
next._setReopener((mode) =>
|
|
759
|
+
document === next
|
|
760
|
+
? reopenBound(next, mode)
|
|
761
|
+
: outside !== null
|
|
762
|
+
? outside(mode)
|
|
763
|
+
: Promise.reject(new BrassError('This document cannot be reopened', { status: 0 })),
|
|
764
|
+
);
|
|
765
|
+
}
|
|
536
766
|
if (next) {
|
|
537
767
|
// `subscribe` fires on every change including local mutations (the
|
|
538
768
|
// session's listeners drive UI that re-renders on local edits). The
|
|
@@ -544,6 +774,7 @@ export function createDocumentSession(
|
|
|
544
774
|
// needs to drop a specified-but-default branch; re-sync in place so
|
|
545
775
|
// the pinned branch settles once the default is known.
|
|
546
776
|
if (event.kind === 'branches') syncUrlForActive('replace');
|
|
777
|
+
if (event.kind === 'change' && event.source !== 'remote') noteTrialEdit(next);
|
|
547
778
|
// Take the doc's name whenever it moves. A rename reaches the doc
|
|
548
779
|
// from the server's `doc_meta` frame, which sets it directly, and
|
|
549
780
|
// `documentName` is what the emit signature reads: without this the
|
|
@@ -580,24 +811,45 @@ export function createDocumentSession(
|
|
|
580
811
|
// keeps its topic subscription and its LoroDoc until it is closed, so
|
|
581
812
|
// every move would otherwise leave one resident for the life of the
|
|
582
813
|
// page, taking a copy of every op the reopened document receives.
|
|
814
|
+
//
|
|
815
|
+
// A guest holds no access by id, so it re-opens through its link,
|
|
816
|
+
// as its first open did.
|
|
817
|
+
//
|
|
818
|
+
// A copy that is already read-only (a reload onto converted or
|
|
819
|
+
// sealed content, or one discarding a refused change) stays bound
|
|
820
|
+
// until the re-opened one replaces it, so the reader keeps seeing
|
|
821
|
+
// the document instead of a loading surface between two copies of
|
|
822
|
+
// it. Nothing can be written to it in the meantime. An editable one
|
|
823
|
+
// is detached as any other open detaches its predecessor.
|
|
583
824
|
const branch = urlBranch(next);
|
|
584
825
|
const evicted = next;
|
|
585
|
-
|
|
586
|
-
|
|
826
|
+
const keepBound = next.readOnly;
|
|
827
|
+
const mode = readOnlyOpens.has(next) ? { readOnly: true } : {};
|
|
828
|
+
const guestToken = deps.guestLinkToken?.();
|
|
829
|
+
const openGuest = deps.openDocumentByLink;
|
|
830
|
+
if (!keepBound) {
|
|
831
|
+
document = null;
|
|
832
|
+
documentName = null;
|
|
833
|
+
}
|
|
587
834
|
void activate(
|
|
588
835
|
(onSnapshotProgress) =>
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
836
|
+
guestToken && openGuest
|
|
837
|
+
? openByLink(openGuest, guestToken, { ...mode, onSnapshotProgress })
|
|
838
|
+
: openById(
|
|
839
|
+
id,
|
|
840
|
+
{
|
|
841
|
+
...withDefaults(),
|
|
842
|
+
...(branch ? { branch } : {}),
|
|
843
|
+
...mode,
|
|
844
|
+
onSnapshotProgress,
|
|
845
|
+
},
|
|
846
|
+
'skip',
|
|
847
|
+
),
|
|
598
848
|
null,
|
|
599
849
|
'skip',
|
|
600
850
|
branch,
|
|
851
|
+
true,
|
|
852
|
+
keepBound,
|
|
601
853
|
)
|
|
602
854
|
.catch(() => markGoneRemote())
|
|
603
855
|
.finally(() => closeQuietly(evicted));
|
|
@@ -703,6 +955,8 @@ export function createDocumentSession(
|
|
|
703
955
|
async function activate(
|
|
704
956
|
load: (
|
|
705
957
|
onSnapshotProgress: (loaded: number, total: number | null) => void,
|
|
958
|
+
onUploadProgress: (loaded: number, total: number) => void,
|
|
959
|
+
releasePrevious: () => void,
|
|
706
960
|
) => Promise<OpenedDocument>,
|
|
707
961
|
knownName: string | null,
|
|
708
962
|
urlNav: UrlNav = 'push',
|
|
@@ -713,9 +967,16 @@ export function createDocumentSession(
|
|
|
713
967
|
// self-heal that should settle silently on empty (the user asked for
|
|
714
968
|
// nothing specific).
|
|
715
969
|
surfaceOpenError = true,
|
|
970
|
+
// Re-opening the document already bound: it stays bound, and the session
|
|
971
|
+
// stays 'ready', until the new copy replaces it. A failure detaches it as
|
|
972
|
+
// though it had been detached at the start.
|
|
973
|
+
keepBound = false,
|
|
974
|
+
// With `keepBound`: a failure leaves the bound document in place and only
|
|
975
|
+
// rejects the caller.
|
|
976
|
+
keepOnFailure = false,
|
|
716
977
|
): Promise<Document> {
|
|
717
978
|
const generation = ++openGeneration;
|
|
718
|
-
loading =
|
|
979
|
+
loading = !keepBound;
|
|
719
980
|
loadingMessage = null;
|
|
720
981
|
loadingProgress = null;
|
|
721
982
|
// A new open attempt supersedes any prior failure's error state.
|
|
@@ -726,11 +987,13 @@ export function createDocumentSession(
|
|
|
726
987
|
// 'loading' until the new doc binds; a failed open restores the previous
|
|
727
988
|
// binding. The doc-event/gone subscriptions stay attached (`setDocument`
|
|
728
989
|
// re-points them, and the restore path watches the same doc).
|
|
729
|
-
|
|
730
|
-
const previousName = documentName;
|
|
990
|
+
let previousDocument = keepBound ? null : document;
|
|
991
|
+
const previousName = keepBound ? null : documentName;
|
|
731
992
|
const previousSpecifiedBranch = activeSpecifiedBranch;
|
|
732
|
-
|
|
733
|
-
|
|
993
|
+
if (!keepBound) {
|
|
994
|
+
document = null;
|
|
995
|
+
documentName = null;
|
|
996
|
+
}
|
|
734
997
|
// What the join's spilled ops have cost so far, and what the whole
|
|
735
998
|
// download was declared to be. Carried across the two phases so the pair
|
|
736
999
|
// reads as one figure. Per open, because a second open of the same
|
|
@@ -743,9 +1006,20 @@ export function createDocumentSession(
|
|
|
743
1006
|
const onSnapshotProgress = (
|
|
744
1007
|
loaded: number,
|
|
745
1008
|
total: number | null,
|
|
746
|
-
phase?: 'snapshot' | 'tail',
|
|
747
|
-
|
|
748
|
-
|
|
1009
|
+
phase?: 'snapshot' | 'tail' | 'preparing' | 'importing',
|
|
1010
|
+
message?: string,
|
|
1011
|
+
): boolean => {
|
|
1012
|
+
if (generation !== openGeneration) return false;
|
|
1013
|
+
// The platform is making the document editable, or replacing a
|
|
1014
|
+
// reader's content, and the open waits for it with nothing bound
|
|
1015
|
+
// meanwhile. A replacement is described in the platform's own words.
|
|
1016
|
+
if (phase === 'preparing' || phase === 'importing') {
|
|
1017
|
+
loadingProgress = null;
|
|
1018
|
+
loadingMessage =
|
|
1019
|
+
phase === 'preparing' ? PREPARING_MESSAGE : message !== undefined && message !== '' ? message : 'Loading...';
|
|
1020
|
+
emit();
|
|
1021
|
+
return true;
|
|
1022
|
+
}
|
|
749
1023
|
// One download, reported once. A document whose ops spilled to storage
|
|
750
1024
|
// fetches them from the join before the snapshot, so a load has two
|
|
751
1025
|
// stretches of bytes arriving over the network with a reader watching.
|
|
@@ -790,6 +1064,7 @@ export function createDocumentSession(
|
|
|
790
1064
|
loadingMessage = `Downloading... ${(loaded / 1_000_000).toFixed(1)} MB`;
|
|
791
1065
|
}
|
|
792
1066
|
emit();
|
|
1067
|
+
return true;
|
|
793
1068
|
};
|
|
794
1069
|
// Give up on this open. A warm swap (previous doc present) rebinds it and
|
|
795
1070
|
// only rejects the caller, so the working surface is never clobbered; with
|
|
@@ -798,6 +1073,13 @@ export function createDocumentSession(
|
|
|
798
1073
|
// and read as the app's document-less home. `display` is what the app
|
|
799
1074
|
// renders; it defaults to the platform's own open-failure copy.
|
|
800
1075
|
function abandonOpen(err: unknown, display?: BrassError): never {
|
|
1076
|
+
if (keepOnFailure && generation === openGeneration) {
|
|
1077
|
+
loading = false;
|
|
1078
|
+
loadingMessage = null;
|
|
1079
|
+
loadingProgress = null;
|
|
1080
|
+
emit();
|
|
1081
|
+
throw err;
|
|
1082
|
+
}
|
|
801
1083
|
if (generation === openGeneration) {
|
|
802
1084
|
loading = false;
|
|
803
1085
|
loadingMessage = null;
|
|
@@ -805,7 +1087,10 @@ export function createDocumentSession(
|
|
|
805
1087
|
// 'replace' restores the URL without planting an entry for an open
|
|
806
1088
|
// that never happened.
|
|
807
1089
|
activeSpecifiedBranch = previousSpecifiedBranch;
|
|
808
|
-
if (
|
|
1090
|
+
if (err instanceof SignInToFollowLink) {
|
|
1091
|
+
// The URL names the document the sign-in returns to.
|
|
1092
|
+
setDocument(previousDocument, previousName, 'skip');
|
|
1093
|
+
} else if (surfaceOpenError && previousDocument === null) {
|
|
809
1094
|
openError = display ?? toDisplayError(err);
|
|
810
1095
|
// Leave the URL pointing at the doc that failed ('skip', not the
|
|
811
1096
|
// 'replace' below that clears it for a null document): the error
|
|
@@ -822,12 +1107,33 @@ export function createDocumentSession(
|
|
|
822
1107
|
}
|
|
823
1108
|
throw err;
|
|
824
1109
|
}
|
|
1110
|
+
// A file the open sends before there is a document to read.
|
|
1111
|
+
const onUploadProgress = (loaded: number, total: number): void => {
|
|
1112
|
+
if (generation !== openGeneration) return;
|
|
1113
|
+
loadingProgress = total > 0 ? Math.min(1, loaded / total) : null;
|
|
1114
|
+
loadingMessage =
|
|
1115
|
+
loadingProgress === null
|
|
1116
|
+
? 'Uploading...'
|
|
1117
|
+
: `Uploading... ${Math.floor(loadingProgress * 100)}%`;
|
|
1118
|
+
emit();
|
|
1119
|
+
};
|
|
1120
|
+
// A load that moves the page onto another document's link takes the
|
|
1121
|
+
// outgoing document's credential with it, so that document closes then
|
|
1122
|
+
// rather than after the bind, and a failed open has nothing to rebind.
|
|
1123
|
+
const releasePrevious = (): void => {
|
|
1124
|
+
closeQuietly(previousDocument);
|
|
1125
|
+
previousDocument = null;
|
|
1126
|
+
};
|
|
825
1127
|
let doc: OpenedDocument;
|
|
1128
|
+
let askedToRead = false;
|
|
826
1129
|
try {
|
|
827
|
-
|
|
1130
|
+
const opening = load(onSnapshotProgress, onUploadProgress, releasePrevious);
|
|
1131
|
+
askedToRead = readOnlyAsks.has(opening);
|
|
1132
|
+
doc = await opening;
|
|
828
1133
|
} catch (err) {
|
|
829
1134
|
abandonOpen(err);
|
|
830
1135
|
}
|
|
1136
|
+
if (askedToRead) readOnlyOpens.add(doc);
|
|
831
1137
|
if (generation !== openGeneration) {
|
|
832
1138
|
// A newer open / close superseded this one. Discard the orphan (so its
|
|
833
1139
|
// WS subscription doesn't leak) plus the doc this generation detached.
|
|
@@ -835,13 +1141,14 @@ export function createDocumentSession(
|
|
|
835
1141
|
closeQuietly(previousDocument);
|
|
836
1142
|
return doc;
|
|
837
1143
|
}
|
|
838
|
-
//
|
|
839
|
-
//
|
|
840
|
-
//
|
|
841
|
-
//
|
|
842
|
-
// and bind only on
|
|
843
|
-
// the content. A pending push is non-blocking and binds
|
|
844
|
-
|
|
1144
|
+
// An open by id resolves with the document's content or rejects, waiting
|
|
1145
|
+
// out any import first. A trial document opened through its link can
|
|
1146
|
+
// arrive empty while the visitor's file is imported, with a pending
|
|
1147
|
+
// blocking status; hold the session in 'loading' (surfacing the server
|
|
1148
|
+
// message) and bind only on the terminal status, so the editor's first
|
|
1149
|
+
// paint sees the content. A pending push is non-blocking and binds
|
|
1150
|
+
// immediately.
|
|
1151
|
+
const work = doc._workStatusNow;
|
|
845
1152
|
if (work !== null && work.pending && work.blocking !== false) {
|
|
846
1153
|
loadingMessage = work.message;
|
|
847
1154
|
loadingProgress = work.progress ?? null;
|
|
@@ -884,7 +1191,7 @@ export function createDocumentSession(
|
|
|
884
1191
|
if (doc._contentLoadFailed) {
|
|
885
1192
|
closeQuietly(doc);
|
|
886
1193
|
const failure = new BrassError(
|
|
887
|
-
doc.
|
|
1194
|
+
doc._workStatusNow?.message ?? OPEN_FAILED_MESSAGE,
|
|
888
1195
|
{ status: 0 },
|
|
889
1196
|
);
|
|
890
1197
|
abandonOpen(failure, failure);
|
|
@@ -923,16 +1230,26 @@ export function createDocumentSession(
|
|
|
923
1230
|
const openGuest = deps.openDocumentByLink;
|
|
924
1231
|
try {
|
|
925
1232
|
return await activate(
|
|
926
|
-
(onSnapshotProgress) =>
|
|
1233
|
+
(onSnapshotProgress) =>
|
|
1234
|
+
openByLink(openGuest, guestToken, inMode({ onSnapshotProgress }, undefined)).catch(
|
|
1235
|
+
async (err: unknown) => {
|
|
1236
|
+
const claimed = await claimedDocumentOf(guestToken, err);
|
|
1237
|
+
if (claimed === null) throw err;
|
|
1238
|
+
if (claimed === 'sign-in') throw new SignInToFollowLink();
|
|
1239
|
+
return openById(claimed.docId, inMode({ onSnapshotProgress }, undefined), 'skip');
|
|
1240
|
+
},
|
|
1241
|
+
),
|
|
927
1242
|
null,
|
|
928
1243
|
'skip',
|
|
929
1244
|
null,
|
|
930
1245
|
);
|
|
931
|
-
} catch {
|
|
1246
|
+
} catch (err) {
|
|
1247
|
+
forgetUnsavedWorkIfGone(guestToken, err);
|
|
932
1248
|
return null;
|
|
933
1249
|
}
|
|
934
1250
|
}
|
|
935
|
-
if (
|
|
1251
|
+
if (document) return document;
|
|
1252
|
+
if (!deps.isSignedIn()) return restoreUnsavedWork();
|
|
936
1253
|
// A pick handed back by the hosted picker's redirect fallback opens
|
|
937
1254
|
// before the ordinary URL / last-selection restore, and independently of
|
|
938
1255
|
// `syncDocToUrl`. `takePickSelection` strips the one-shot params as it
|
|
@@ -945,12 +1262,15 @@ export function createDocumentSession(
|
|
|
945
1262
|
// navigation, so it must not plant a history entry.
|
|
946
1263
|
return await activate(
|
|
947
1264
|
(onSnapshotProgress) =>
|
|
948
|
-
|
|
1265
|
+
openById(
|
|
949
1266
|
sel.docId,
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
1267
|
+
inMode(
|
|
1268
|
+
{
|
|
1269
|
+
...(sel.branchId ? { branch: sel.branchId } : {}),
|
|
1270
|
+
onSnapshotProgress,
|
|
1271
|
+
},
|
|
1272
|
+
undefined,
|
|
1273
|
+
),
|
|
954
1274
|
'skip',
|
|
955
1275
|
),
|
|
956
1276
|
null,
|
|
@@ -972,7 +1292,7 @@ export function createDocumentSession(
|
|
|
972
1292
|
try {
|
|
973
1293
|
return await activate(
|
|
974
1294
|
(onSnapshotProgress) =>
|
|
975
|
-
|
|
1295
|
+
openById(last.doc_id, inMode({ onSnapshotProgress }, undefined), 'skip'),
|
|
976
1296
|
last.name,
|
|
977
1297
|
'replace',
|
|
978
1298
|
null,
|
|
@@ -988,6 +1308,73 @@ export function createDocumentSession(
|
|
|
988
1308
|
return null;
|
|
989
1309
|
}
|
|
990
1310
|
|
|
1311
|
+
// Whether a failed link open says the link itself is gone. The view route
|
|
1312
|
+
// answers `404` for a token it does not know, `410` for one that expired
|
|
1313
|
+
// or was revoked and `403` for one its organization's policy now refuses;
|
|
1314
|
+
// anything else (no response, a shed request) says nothing about the link.
|
|
1315
|
+
function linkGone(err: unknown): boolean {
|
|
1316
|
+
return (
|
|
1317
|
+
err instanceof BrassError &&
|
|
1318
|
+
(err.status === 403 || err.status === 404 || err.status === 410)
|
|
1319
|
+
);
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
// The document a revoked link leads this person to, when their own claim
|
|
1323
|
+
// revoked it. Asked only on a 410, the one refusal a claim produces.
|
|
1324
|
+
async function claimedDocumentOf(token: string, err: unknown): Promise<ClaimedLinkTarget> {
|
|
1325
|
+
if (!(err instanceof BrassError) || err.status !== 410 || !deps.followClaimedLink) {
|
|
1326
|
+
return null;
|
|
1327
|
+
}
|
|
1328
|
+
try {
|
|
1329
|
+
return await deps.followClaimedLink(token);
|
|
1330
|
+
} catch {
|
|
1331
|
+
return null;
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
// A stashed token the platform no longer answers names nothing to
|
|
1336
|
+
// continue, so it is forgotten where the refusal is heard, whether the
|
|
1337
|
+
// open was the boot of its URL or the restore below.
|
|
1338
|
+
function forgetUnsavedWorkIfGone(token: string, err: unknown): void {
|
|
1339
|
+
if (!linkGone(err)) return;
|
|
1340
|
+
if (deps.unsavedWork?.()?.token === token) deps.clearUnsavedWork?.();
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
// A signed-out boot on a URL that names no document reopens the last
|
|
1344
|
+
// document this browser made without an account on this app, the way a
|
|
1345
|
+
// signed-in boot reopens the last document picked.
|
|
1346
|
+
async function restoreUnsavedWork(): Promise<Document | null> {
|
|
1347
|
+
if (deps.readUrlSelection()) return null;
|
|
1348
|
+
const entry = deps.unsavedWork?.();
|
|
1349
|
+
if (!entry || !deps.enterGuestMode || !deps.openDocumentByLink) return null;
|
|
1350
|
+
const openGuest = deps.openDocumentByLink;
|
|
1351
|
+
const { token } = entry;
|
|
1352
|
+
// The same path a reload of the document's URL takes: the token becomes
|
|
1353
|
+
// the page's credential and the URL carries it, then the link opens.
|
|
1354
|
+
deps.enterGuestMode(token);
|
|
1355
|
+
try {
|
|
1356
|
+
return await activate(
|
|
1357
|
+
(onSnapshotProgress) =>
|
|
1358
|
+
openByLink(
|
|
1359
|
+
openGuest,
|
|
1360
|
+
token,
|
|
1361
|
+
inMode({ ...withDefaults({}), onSnapshotProgress }, undefined),
|
|
1362
|
+
),
|
|
1363
|
+
null,
|
|
1364
|
+
'skip',
|
|
1365
|
+
);
|
|
1366
|
+
} catch (err) {
|
|
1367
|
+
// The token did not open, so it is not this page's credential after
|
|
1368
|
+
// all: the page is signed out again, and the stash is kept unless the
|
|
1369
|
+
// link is gone for good, so the next boot tries it again.
|
|
1370
|
+
deps.leaveGuestMode?.();
|
|
1371
|
+
openError = null;
|
|
1372
|
+
forgetUnsavedWorkIfGone(token, err);
|
|
1373
|
+
emit();
|
|
1374
|
+
return null;
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
|
|
991
1378
|
function start(): Promise<Document | null> {
|
|
992
1379
|
if (startPromise) return startPromise;
|
|
993
1380
|
startPromise = doStart().finally(() => {
|
|
@@ -1005,20 +1392,133 @@ export function createDocumentSession(
|
|
|
1005
1392
|
docId: string,
|
|
1006
1393
|
options?: SessionOpenOptions,
|
|
1007
1394
|
): Promise<Document> {
|
|
1395
|
+
if ((options as { frozen?: unknown } | undefined)?.frozen === true) {
|
|
1396
|
+
return Promise.reject(new BrassError(FROZEN_IN_SESSION, { status: 0 }));
|
|
1397
|
+
}
|
|
1398
|
+
const asked = options?.readOnly ?? options?.documentOptions?.readOnly;
|
|
1008
1399
|
return activate(
|
|
1009
1400
|
(onSnapshotProgress) =>
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
onSnapshotProgress,
|
|
1013
|
-
|
|
1401
|
+
openById(
|
|
1402
|
+
docId,
|
|
1403
|
+
inMode({ ...withDefaults(options), onSnapshotProgress }, asked),
|
|
1404
|
+
),
|
|
1014
1405
|
null,
|
|
1015
1406
|
'push',
|
|
1016
1407
|
options?.branch ?? null,
|
|
1017
1408
|
);
|
|
1018
1409
|
}
|
|
1019
1410
|
|
|
1411
|
+
// A switch the hosted history or branch surfaces make from the bound
|
|
1412
|
+
// document (another branch, a version, a fork) opens in the mode that
|
|
1413
|
+
// document was opened in, so a copy opened to read stays one and a copy
|
|
1414
|
+
// reopened to edit stays one.
|
|
1415
|
+
function openSibling(docId: string, options?: SessionOpenOptions): Promise<Document> {
|
|
1416
|
+
const current = document;
|
|
1417
|
+
const readOnly =
|
|
1418
|
+
current === null || current.id !== docId
|
|
1419
|
+
? undefined
|
|
1420
|
+
: readOnlyOpens.has(current)
|
|
1421
|
+
? true
|
|
1422
|
+
: current._openMode === 'edit'
|
|
1423
|
+
? false
|
|
1424
|
+
: undefined;
|
|
1425
|
+
return open(docId, { ...options, ...(readOnly !== undefined ? { readOnly } : {}) });
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
// `reopen` on the bound document: the new copy replaces it the way a move's
|
|
1429
|
+
// reopen does, kept on screen until it binds and kept bound if the reopen
|
|
1430
|
+
// fails. The session holds a live copy, so a frozen one is the caller's to
|
|
1431
|
+
// open beside it.
|
|
1432
|
+
async function reopenBound(current: Document, mode: ReopenMode): Promise<OpenedDocument> {
|
|
1433
|
+
if (mode === 'frozen') {
|
|
1434
|
+
throw new BrassError(FROZEN_IN_SESSION, { status: 0 });
|
|
1435
|
+
}
|
|
1436
|
+
const id = current.id;
|
|
1437
|
+
if (id === null) throw new BrassError('No document is open', { status: 0 });
|
|
1438
|
+
const branch = urlBranch(current);
|
|
1439
|
+
const guestToken = deps.guestLinkToken?.();
|
|
1440
|
+
const openGuest = deps.openDocumentByLink;
|
|
1441
|
+
const readOnly = mode === 'read';
|
|
1442
|
+
const next = await activate(
|
|
1443
|
+
(onSnapshotProgress) =>
|
|
1444
|
+
guestToken && openGuest
|
|
1445
|
+
? openByLink(openGuest, guestToken, {
|
|
1446
|
+
...withDefaults(),
|
|
1447
|
+
readOnly,
|
|
1448
|
+
onSnapshotProgress,
|
|
1449
|
+
})
|
|
1450
|
+
: openById(id, {
|
|
1451
|
+
...withDefaults(),
|
|
1452
|
+
...(branch ? { branch } : {}),
|
|
1453
|
+
readOnly,
|
|
1454
|
+
onSnapshotProgress,
|
|
1455
|
+
}),
|
|
1456
|
+
documentName,
|
|
1457
|
+
'skip',
|
|
1458
|
+
activeSpecifiedBranch,
|
|
1459
|
+
true,
|
|
1460
|
+
true,
|
|
1461
|
+
true,
|
|
1462
|
+
);
|
|
1463
|
+
// A newer open, reopen or close took the session while this one joined,
|
|
1464
|
+
// and closed the copy it produced.
|
|
1465
|
+
if (document !== next) {
|
|
1466
|
+
throw new BrassError('The reopen was replaced by a later open of the session', {
|
|
1467
|
+
status: 0,
|
|
1468
|
+
});
|
|
1469
|
+
}
|
|
1470
|
+
return next as OpenedDocument;
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
// A create fails in two regimes, and only the later one reaches the
|
|
1474
|
+
// session. Once `activate` has the open, `abandonOpen` parks the failure on
|
|
1475
|
+
// `session.error` for a status-gated app to render. Everything before that
|
|
1476
|
+
// (minting the account-less document, resolving the destination) throws with
|
|
1477
|
+
// the returned promise as its only report, and the documented way to call
|
|
1478
|
+
// this from a button floats that promise, so the click reads as ignored.
|
|
1479
|
+
// Parking here under `abandonOpen`'s own condition gives both regimes one
|
|
1480
|
+
// surface. The generation check is what keeps a failure from clobbering a
|
|
1481
|
+
// newer open that started while this one was still resolving a destination.
|
|
1482
|
+
// The trial token this page last recorded an edit for, so a burst of
|
|
1483
|
+
// edits writes the marker once.
|
|
1484
|
+
let trialEditNoted: string | null = null;
|
|
1485
|
+
function noteTrialEdit(doc: Document): void {
|
|
1486
|
+
const token = deps.guestLinkToken?.();
|
|
1487
|
+
if (!token || doc.expiresAt === null || trialEditNoted === token) return;
|
|
1488
|
+
trialEditNoted = token;
|
|
1489
|
+
deps.markTrialEdited?.(token, doc.expiresAt);
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
// Whether New or Upload may replace the document on screen. A document
|
|
1493
|
+
// made without an account that this browser edited is kept only at its
|
|
1494
|
+
// link, so the visitor is asked first, and signing in is what keeps it.
|
|
1495
|
+
async function mayLeaveTrial(): Promise<boolean> {
|
|
1496
|
+
const token = deps.guestLinkToken?.();
|
|
1497
|
+
const doc = document;
|
|
1498
|
+
if (!token || !doc || doc.expiresAt === null) return true;
|
|
1499
|
+
if (!deps.confirmReplaceTrial || !deps.trialEdited?.(token)) return true;
|
|
1500
|
+
const answer = await deps.confirmReplaceTrial(doc.expiresAt);
|
|
1501
|
+
if (answer === 'sign-in') deps.signIn?.();
|
|
1502
|
+
return answer === 'leave';
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1020
1505
|
async function create(
|
|
1021
1506
|
options?: CreateDocumentOptions,
|
|
1507
|
+
): Promise<Document | null> {
|
|
1508
|
+
const generation = openGeneration;
|
|
1509
|
+
try {
|
|
1510
|
+
return await createUnguarded(options);
|
|
1511
|
+
} catch (err) {
|
|
1512
|
+
if (generation === openGeneration && document === null && openError === null) {
|
|
1513
|
+
openError = toDisplayError(err);
|
|
1514
|
+
emit();
|
|
1515
|
+
}
|
|
1516
|
+
throw err;
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
async function createUnguarded(
|
|
1521
|
+
options?: CreateDocumentOptions,
|
|
1022
1522
|
): Promise<Document | null> {
|
|
1023
1523
|
// Placement is expressed by context, never an org this layer names.
|
|
1024
1524
|
// Create-in-place: a New doc anchors on the active one via `sibling_of`,
|
|
@@ -1037,29 +1537,62 @@ export function createDocumentSession(
|
|
|
1037
1537
|
// one. The server owns the single-vs-multi decision: a `needs-*` outcome
|
|
1038
1538
|
// is where we open the corresponding hosted surface and retry with the
|
|
1039
1539
|
// opaque destination it returns, relayed verbatim.
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
// needs-destination
|
|
1053
|
-
const destination = await deps.selectCreateDestination(
|
|
1054
|
-
options?.name !== undefined ? { name: options.name } : undefined,
|
|
1540
|
+
// No account: the document is minted without one and opened through
|
|
1541
|
+
// its link token, which the URL then carries so a reload and a sign-in
|
|
1542
|
+
// both find it again.
|
|
1543
|
+
if (
|
|
1544
|
+
withoutAccount() &&
|
|
1545
|
+
deps.mintAnonymousDocument &&
|
|
1546
|
+
deps.enterGuestMode &&
|
|
1547
|
+
deps.openDocumentByLink
|
|
1548
|
+
) {
|
|
1549
|
+
if (!(await mayLeaveTrial())) return null;
|
|
1550
|
+
const minted = await deps.mintAnonymousDocument(
|
|
1551
|
+
options?.name !== undefined ? { name: options.name } : {},
|
|
1055
1552
|
);
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1553
|
+
const enterGuestMode = deps.enterGuestMode;
|
|
1554
|
+
const openGuest = deps.openDocumentByLink;
|
|
1555
|
+
accountFreeOpen = true;
|
|
1556
|
+
try {
|
|
1557
|
+
return await activate(
|
|
1558
|
+
(onSnapshotProgress, _onUploadProgress, releasePrevious) => {
|
|
1559
|
+
releasePrevious();
|
|
1560
|
+
enterGuestMode(minted.linkToken);
|
|
1561
|
+
return openGuest(minted.linkToken, {
|
|
1562
|
+
...withDefaults(
|
|
1563
|
+
options?.documentOptions
|
|
1564
|
+
? { documentOptions: options.documentOptions }
|
|
1565
|
+
: undefined,
|
|
1566
|
+
),
|
|
1567
|
+
onSnapshotProgress,
|
|
1568
|
+
});
|
|
1569
|
+
},
|
|
1570
|
+
options?.name ?? null,
|
|
1571
|
+
'skip',
|
|
1572
|
+
);
|
|
1573
|
+
} finally {
|
|
1574
|
+
accountFreeOpen = false;
|
|
1575
|
+
}
|
|
1061
1576
|
}
|
|
1062
1577
|
|
|
1578
|
+
let args = base;
|
|
1579
|
+
let knownName = options?.name ?? null;
|
|
1580
|
+
const result = await placeThroughHostedSurfaces(
|
|
1581
|
+
deps,
|
|
1582
|
+
(destination) =>
|
|
1583
|
+
deps.createDocumentId(
|
|
1584
|
+
destination === undefined ? args : { ...args, destination },
|
|
1585
|
+
),
|
|
1586
|
+
options?.name,
|
|
1587
|
+
(chosen) => {
|
|
1588
|
+
// The dialog's final name wins on both the wire and the display: the
|
|
1589
|
+
// user saw and could edit it.
|
|
1590
|
+
args = { ...args, name: chosen.name };
|
|
1591
|
+
knownName = chosen.name;
|
|
1592
|
+
},
|
|
1593
|
+
);
|
|
1594
|
+
if (result === null) return null;
|
|
1595
|
+
|
|
1063
1596
|
const docId = result.docId;
|
|
1064
1597
|
const openOptions = options?.documentOptions
|
|
1065
1598
|
? { documentOptions: options.documentOptions }
|
|
@@ -1070,7 +1603,7 @@ export function createDocumentSession(
|
|
|
1070
1603
|
// stored name once the record binds.
|
|
1071
1604
|
return activate(
|
|
1072
1605
|
(onSnapshotProgress) =>
|
|
1073
|
-
|
|
1606
|
+
openById(docId, {
|
|
1074
1607
|
...withDefaults(openOptions),
|
|
1075
1608
|
onSnapshotProgress,
|
|
1076
1609
|
}),
|
|
@@ -1078,16 +1611,78 @@ export function createDocumentSession(
|
|
|
1078
1611
|
);
|
|
1079
1612
|
}
|
|
1080
1613
|
|
|
1614
|
+
// The upload runs inside the open, so the app renders its loading surface
|
|
1615
|
+
// with the upload's progress rather than an unchanged screen. A failure
|
|
1616
|
+
// lands on `session.error` the way a failed create's does.
|
|
1617
|
+
async function pickAnonymousFile(
|
|
1618
|
+
choose: () => Promise<File | null>,
|
|
1619
|
+
mint: NonNullable<DocumentSessionDeps['mintAnonymousDocumentFromFile']>,
|
|
1620
|
+
enterGuestMode: (token: string) => void,
|
|
1621
|
+
openGuest: NonNullable<DocumentSessionDeps['openDocumentByLink']>,
|
|
1622
|
+
): Promise<Document | null> {
|
|
1623
|
+
const generation = openGeneration;
|
|
1624
|
+
let file: File | null;
|
|
1625
|
+
try {
|
|
1626
|
+
file = await choose();
|
|
1627
|
+
} catch (err) {
|
|
1628
|
+
// Before `activate` there is no open to abandon, so the refusal is
|
|
1629
|
+
// parked here under `create`'s condition.
|
|
1630
|
+
if (generation === openGeneration && document === null && openError === null) {
|
|
1631
|
+
openError = toDisplayError(err);
|
|
1632
|
+
emit();
|
|
1633
|
+
}
|
|
1634
|
+
throw err;
|
|
1635
|
+
}
|
|
1636
|
+
if (file === null) return null;
|
|
1637
|
+
accountFreeOpen = true;
|
|
1638
|
+
try {
|
|
1639
|
+
return await activate(
|
|
1640
|
+
async (onSnapshotProgress, onUploadProgress, releasePrevious) => {
|
|
1641
|
+
const minted = await mint({ file, onUploadProgress });
|
|
1642
|
+
releasePrevious();
|
|
1643
|
+
enterGuestMode(minted.linkToken);
|
|
1644
|
+
return openGuest(minted.linkToken, {
|
|
1645
|
+
...withDefaults(),
|
|
1646
|
+
onSnapshotProgress,
|
|
1647
|
+
});
|
|
1648
|
+
},
|
|
1649
|
+
null,
|
|
1650
|
+
'skip',
|
|
1651
|
+
);
|
|
1652
|
+
} finally {
|
|
1653
|
+
accountFreeOpen = false;
|
|
1654
|
+
emit();
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1081
1658
|
async function pick(): Promise<Document | null> {
|
|
1659
|
+
if (
|
|
1660
|
+
withoutAccount() &&
|
|
1661
|
+
deps.chooseAnonymousFile &&
|
|
1662
|
+
deps.mintAnonymousDocumentFromFile &&
|
|
1663
|
+
deps.enterGuestMode &&
|
|
1664
|
+
deps.openDocumentByLink
|
|
1665
|
+
) {
|
|
1666
|
+
if (!(await mayLeaveTrial())) return null;
|
|
1667
|
+
return pickAnonymousFile(
|
|
1668
|
+
deps.chooseAnonymousFile,
|
|
1669
|
+
deps.mintAnonymousDocumentFromFile,
|
|
1670
|
+
deps.enterGuestMode,
|
|
1671
|
+
deps.openDocumentByLink,
|
|
1672
|
+
);
|
|
1673
|
+
}
|
|
1082
1674
|
const result = await deps.selectDocument();
|
|
1083
1675
|
if (!result) return null;
|
|
1084
|
-
const opts =
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1676
|
+
const opts = inMode(
|
|
1677
|
+
{
|
|
1678
|
+
...withDefaults(),
|
|
1679
|
+
...(result.branch_id ? { branch: result.branch_id } : {}),
|
|
1680
|
+
},
|
|
1681
|
+
undefined,
|
|
1682
|
+
);
|
|
1088
1683
|
return activate(
|
|
1089
1684
|
(onSnapshotProgress) =>
|
|
1090
|
-
|
|
1685
|
+
openById(result.doc_id, { ...opts, onSnapshotProgress }),
|
|
1091
1686
|
result.name,
|
|
1092
1687
|
'push',
|
|
1093
1688
|
result.branch_id ?? null,
|
|
@@ -1182,13 +1777,16 @@ export function createDocumentSession(
|
|
|
1182
1777
|
}
|
|
1183
1778
|
void activate(
|
|
1184
1779
|
(onSnapshotProgress) =>
|
|
1185
|
-
|
|
1780
|
+
openById(
|
|
1186
1781
|
sel.docId,
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1782
|
+
inMode(
|
|
1783
|
+
{
|
|
1784
|
+
...withDefaults(),
|
|
1785
|
+
...(sel.branchId ? { branch: sel.branchId } : {}),
|
|
1786
|
+
onSnapshotProgress,
|
|
1787
|
+
},
|
|
1788
|
+
undefined,
|
|
1789
|
+
),
|
|
1192
1790
|
'skip',
|
|
1193
1791
|
),
|
|
1194
1792
|
null,
|
|
@@ -1220,12 +1818,22 @@ export function createDocumentSession(
|
|
|
1220
1818
|
// (opened via `?brass_link=`) is never signed in, so the initial
|
|
1221
1819
|
// resolution arrives here with `signedIn === false`; it must boot the
|
|
1222
1820
|
// read-only link document rather than tear down to the sign-in surface.
|
|
1821
|
+
//
|
|
1822
|
+
// The first resolution to signed-out is the boot of a signed-out page, which
|
|
1823
|
+
// restores the unsaved work this browser made (`restoreUnsavedWork`); a
|
|
1824
|
+
// later sign-out lands on the sign-in surface.
|
|
1825
|
+
let authResolved = false;
|
|
1223
1826
|
deps.onUserChange((signedIn) => {
|
|
1827
|
+
const boot = !authResolved;
|
|
1828
|
+
authResolved = true;
|
|
1224
1829
|
if (signedIn || deps.guestLinkToken?.() != null) {
|
|
1225
1830
|
if (!document) void start();
|
|
1226
1831
|
else emit();
|
|
1227
1832
|
} else {
|
|
1228
1833
|
teardownForSignOut();
|
|
1834
|
+
// Deferred: a session made after `init` settled hears this resolution
|
|
1835
|
+
// while it is still being built.
|
|
1836
|
+
if (boot) queueMicrotask(() => void start());
|
|
1229
1837
|
}
|
|
1230
1838
|
});
|
|
1231
1839
|
|
|
@@ -1240,7 +1848,17 @@ export function createDocumentSession(
|
|
|
1240
1848
|
return status();
|
|
1241
1849
|
},
|
|
1242
1850
|
get error(): BrassError | null {
|
|
1243
|
-
|
|
1851
|
+
// Not gated on `status() === 'error'`. A signed-out visitor's failed
|
|
1852
|
+
// create is the case that gating loses: 'signed-out' outranks the error
|
|
1853
|
+
// in `status()` (the app must keep rendering its sign-in surface, which
|
|
1854
|
+
// is where the retry lives), so gating here left the try-before-signing-in
|
|
1855
|
+
// failure with no channel at all.
|
|
1856
|
+
// What makes ungating safe is that a parked error never coexists with a
|
|
1857
|
+
// bound document: `activate` clears it on entry and is the only path that
|
|
1858
|
+
// binds a non-null one, and `abandonOpen` parks only when there was no
|
|
1859
|
+
// document to fall back to. So this can never show a stale failure beside
|
|
1860
|
+
// a working document. A new write to `openError` has to hold that.
|
|
1861
|
+
return openError ?? deps.initFailure?.() ?? null;
|
|
1244
1862
|
},
|
|
1245
1863
|
get loadingMessage(): string | null {
|
|
1246
1864
|
return loading ? loadingMessage : null;
|
|
@@ -1259,19 +1877,40 @@ export function createDocumentSession(
|
|
|
1259
1877
|
try {
|
|
1260
1878
|
listener(session);
|
|
1261
1879
|
} catch (err) {
|
|
1262
|
-
|
|
1880
|
+
reportListenerThrow(err);
|
|
1263
1881
|
}
|
|
1264
1882
|
return () => {
|
|
1265
1883
|
listeners.delete(listener);
|
|
1266
1884
|
};
|
|
1267
1885
|
},
|
|
1268
1886
|
start,
|
|
1269
|
-
open,
|
|
1887
|
+
open: ((docId?: string, options?: SessionOpenOptions) =>
|
|
1888
|
+
docId === undefined ? pick() : open(docId, options)) as DocumentSession['open'],
|
|
1270
1889
|
create,
|
|
1271
1890
|
pick,
|
|
1272
1891
|
close,
|
|
1273
1892
|
async run(commandId): Promise<void> {
|
|
1274
1893
|
const command = allCommands().find((c) => c.id === commandId);
|
|
1894
|
+
// An app's own signed-out button may still run a withdrawn New or Open,
|
|
1895
|
+
// and the reader is owed the reason rather than a press that does
|
|
1896
|
+
// nothing.
|
|
1897
|
+
if (
|
|
1898
|
+
command &&
|
|
1899
|
+
command.group === 'session' &&
|
|
1900
|
+
!command.enabled &&
|
|
1901
|
+
withoutAccount() &&
|
|
1902
|
+
signedOutRefusal !== null
|
|
1903
|
+
) {
|
|
1904
|
+
// A parked error never sits beside a bound document, so a guest
|
|
1905
|
+
// reading one hears the refusal through `onError` instead.
|
|
1906
|
+
if (document === null) {
|
|
1907
|
+
openError = signedOutRefusal;
|
|
1908
|
+
emit();
|
|
1909
|
+
} else {
|
|
1910
|
+
deps.reportError(signedOutRefusal);
|
|
1911
|
+
}
|
|
1912
|
+
return;
|
|
1913
|
+
}
|
|
1275
1914
|
if (command) await command.run();
|
|
1276
1915
|
},
|
|
1277
1916
|
useCanvas(source): () => void {
|
|
@@ -1311,8 +1950,9 @@ export function createDocumentSession(
|
|
|
1311
1950
|
}
|
|
1312
1951
|
// Already-terminal open failure: reject now rather than wait for a bind
|
|
1313
1952
|
// that isn't coming.
|
|
1314
|
-
|
|
1315
|
-
|
|
1953
|
+
const failure = session.error;
|
|
1954
|
+
if (status() === 'error' && failure) {
|
|
1955
|
+
return Promise.reject(failure);
|
|
1316
1956
|
}
|
|
1317
1957
|
return new Promise((resolve, reject) => {
|
|
1318
1958
|
whenReadyPending.push({ resolve, reject });
|
|
@@ -1330,6 +1970,7 @@ export function createDocumentSession(
|
|
|
1330
1970
|
_clearLocalDelete(docId: string): void {
|
|
1331
1971
|
localDeletes.delete(docId);
|
|
1332
1972
|
},
|
|
1973
|
+
_openSibling: openSibling,
|
|
1333
1974
|
};
|
|
1334
1975
|
|
|
1335
1976
|
return session;
|