@nauticana/sail 1.1.21 → 1.1.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +158 -6
- package/package.json +1 -1
- package/src/agent/component/turn_stream_view.ts +3 -3
- package/src/agent/index.ts +3 -0
- package/src/agent/model/conversation.ts +35 -0
- package/src/agent/model/reply_frame.ts +2 -2
- package/src/agent/service/conversation.service.ts +180 -0
- package/src/agent/service/turn_stream.ts +3 -3
- package/src/agent/util/sse.ts +63 -0
- package/src/component/dashboard/data_card.html +3 -1
- package/src/component/dashboard/no_source_overlay.html +3 -1
- package/src/component/dashboard/verification_flow.html +17 -4
- package/src/component/dashboard/verification_flow.ts +57 -2
- package/src/component/notification/notification_center.html +60 -0
- package/src/component/notification/notification_center.ts +69 -0
- package/src/component/verification/domain_verification.html +23 -0
- package/src/component/verification/domain_verification.ts +85 -0
- package/src/index.ts +5 -0
- package/src/model/notification.ts +20 -0
- package/src/service/domain_verifier.ts +20 -0
- package/src/service/notification_inbox.service.ts +78 -0
- package/src/service/rest_url.ts +4 -0
package/README.md
CHANGED
|
@@ -23,11 +23,12 @@ A shared Angular component library for building CRUD-based admin frontends. Prov
|
|
|
23
23
|
| **Dashboard** | `DashboardShellComponent`, `DataCardComponent`, `LockedOverlayComponent`, `ActionCenterComponent`, `EntitySelectorComponent`, `VerificationFlowComponent` |
|
|
24
24
|
| **Payout** | `PayoutProviderOnboardingComponent`, `PayoutBankInfoFormComponent` |
|
|
25
25
|
| **Agency** | `AgencyClientsComponent`, `AgencyStatusBannerComponent`, `AgencyAcceptComponent`, `AgencyEarningsSummaryComponent`, `CommissionLedgerComponent`, `AgencyPayoutHistoryComponent`, `AgencyPayoutProfileComponent` |
|
|
26
|
+
| **Notifications** | `NotificationCenterComponent`, `NotificationInboxService` |
|
|
26
27
|
| **Payments / SCA** | `UserPaymentMethodsComponent`, `ScaConfirmComponent`, `ScaConfirmer` (port), `SCA_CONFIRMER` (token) |
|
|
27
|
-
| **Services** | `BaseAuthService` (OTP / social / push / deleteAccount / logoutEverywhere / profile: `getProfile`, `updateProfile`, `request`+`confirmEmailChange`, `request`+`confirmPhoneChange`), `BillingService`, `AgencyService`, `BackendService`, `PayoutService`, `UserPaymentMethodService`, `loadScript()`, `authInterceptor`, `apiResponseInterceptor` |
|
|
28
|
+
| **Services** | `BaseAuthService` (OTP / social / push / deleteAccount / logoutEverywhere / profile: `getProfile`, `updateProfile`, `request`+`confirmEmailChange`, `request`+`confirmPhoneChange`), `BillingService`, `AgencyService`, `BackendService`, `PayoutService`, `UserPaymentMethodService`, `NotificationInboxService`, `loadScript()`, `authInterceptor`, `apiResponseInterceptor` |
|
|
28
29
|
| **Abstracts** | `BaseTable`, `BaseForm`, `BaseView`, `AbstractEditableView`, `BaseAsync`, `BaseRestService` |
|
|
29
30
|
| **Config** | `SAIL_GUI_CONFIG`, `SailGuiConfig`, `configureRestUrls()` |
|
|
30
|
-
| **Models** | `ApplicationData`, `TableDefinition`, `SiudAction`, `ApplicationMenu`, `ConstantValue`, `UserAccount`, `RestReport`, `ReportParam`, `TrustedDevice`, `PublicPlan`, `PlanPrice`, `PaymentMethod`, `Subscription`, `Invoice`, `CheckoutRequest`/`CheckoutResponse`, `PortalResponse`, `UsageMeter`, `ChargeResult`, `UserPaymentMethod`, `TableAction`, `ReusableAccount`, `PayoutOnboardingSession`, `BankInfoFormValue`, `CountryProfile`, `OtpRequest`/`OtpResponse`, `SignupConsent`, `ConsentState`, `ConsentOption`, `SocialProvider`, `PushPlatform`, 2FA types, etc. |
|
|
31
|
+
| **Models** | `ApplicationData`, `TableDefinition`, `SiudAction`, `ApplicationMenu`, `ConstantValue`, `UserAccount`, `RestReport`, `ReportParam`, `TrustedDevice`, `PublicPlan`, `PlanPrice`, `PaymentMethod`, `Subscription`, `Invoice`, `CheckoutRequest`/`CheckoutResponse`, `PortalResponse`, `UsageMeter`, `ChargeResult`, `UserPaymentMethod`, `TableAction`, `ReusableAccount`, `PayoutOnboardingSession`, `BankInfoFormValue`, `CountryProfile`, `OtpRequest`/`OtpResponse`, `SignupConsent`, `ConsentState`, `ConsentOption`, `SocialProvider`, `PushPlatform`, `InboxMessage`/`InboxPage`, 2FA types, etc. |
|
|
31
32
|
| **Decorators** | `@IsSailString()`, `@IsSailNumeric()` (class-validator based) |
|
|
32
33
|
| **Utils** | `titleCase()`, `fromPriceLabel()`, `formatMinorCurrency()` |
|
|
33
34
|
|
|
@@ -342,6 +343,9 @@ Omit the URL to skip client-side policy checks. The server stays the authoritati
|
|
|
342
343
|
| `POST /api/user/trusted-device/revoke` | Revoke a trusted device |
|
|
343
344
|
| `POST /api/user/logout-everywhere` | Sign out of all devices — **requires re-auth** |
|
|
344
345
|
| `DELETE /api/user/account` | Soft-delete the caller's account — **requires re-auth** |
|
|
346
|
+
| `GET /api/notifications` | Notification inbox page — `?before=<id>&limit=<n>`, newest first (keel v1.2.71) |
|
|
347
|
+
| `POST /api/notifications/mark_read` | Mark one message read — `{"id": "<id>"}` |
|
|
348
|
+
| `POST /api/notifications/mark_all_read` | Mark the caller's whole inbox read |
|
|
345
349
|
| `POST /api/push/register` | Register an FCM / APNs token |
|
|
346
350
|
| `POST /api/push/revoke` | Revoke an FCM / APNs token |
|
|
347
351
|
| `POST /api/billing/checkout` | Create provider-hosted checkout session — **JWT-gated, allowlist-validated** |
|
|
@@ -649,8 +653,56 @@ import { AgentStudioService, PromptLanguageEditorComponent, TurnStream } from '@
|
|
|
649
653
|
| `TurnStream` | Per-turn stream state: `accept(frame)` accepts Scout's zero-based frames and drops frames already rendered, `cursor()` is the next sequence wanted for inclusive replay, a suspension frame (`approval_pending`, even when marked final) sets `awaiting_approval` without advancing the cursor so the resumed frame at the same sequence is accepted, `markReplayExpired()` + `acceptStoredResult(frame)` handle an expired replay window, `markCancelRequested()` holds `cancelling` until the final frame. |
|
|
650
654
|
| `sail-turn-stream-view` | `TurnStream` + event list + status + cancel button (`cancelRequested`). |
|
|
651
655
|
| `sail-decision-timeline` | Read-only decision chain of one request, grouped by category. |
|
|
656
|
+
| `ConversationService` | Transport for Scout's conversation bridge: `submit`, `stream(requestId, turnStream)`, `cancel`. Mount point injected through `CONVERSATION_BASE_PATH`. |
|
|
657
|
+
| `SseParser` | Incremental `text/event-stream` parser: `push(chunk)` returns whole events out of chunks that split anywhere, including between the CR and LF of a CRLF; `finish()` flushes what the closing body left pending. |
|
|
652
658
|
|
|
653
|
-
|
|
659
|
+
Map your audit rows onto `DecisionRecord` to feed the timeline. Style the `.prompt-*`, `.release-*`, `.turn-*`, and `.decision-*` class hooks.
|
|
660
|
+
|
|
661
|
+
### Conversation transport (v1.1.22)
|
|
662
|
+
|
|
663
|
+
Scout `v0.7.1` fixed the wire contract, so the transport is no longer app-specific.
|
|
664
|
+
`ConversationService` speaks it and feeds a `TurnStream`; the app supplies the mount
|
|
665
|
+
point, the turn `input` payload, and its own renderers.
|
|
666
|
+
|
|
667
|
+
```ts
|
|
668
|
+
providers: [{ provide: CONVERSATION_BASE_PATH, useValue: '/api/wingmate/' }]
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
```ts
|
|
672
|
+
const stream = new TurnStream(requestId);
|
|
673
|
+
this.conversation.submit({ request_id: requestId, conversation_id: conversationId, agent_id: 'wingmate', input })
|
|
674
|
+
.pipe(switchMap(() => this.conversation.stream(requestId, stream)), takeUntilDestroyed())
|
|
675
|
+
.subscribe({ error: (err) => this.onStreamError(err) });
|
|
676
|
+
```
|
|
677
|
+
|
|
678
|
+
| Method | Wire |
|
|
679
|
+
|---|---|
|
|
680
|
+
| `submit(request)` | `POST {base}turn` → `202 {request_id}`. Goes through `HttpClient`, so the interceptors apply. |
|
|
681
|
+
| `stream(requestId, turnStream)` | `GET {base}turn/stream?request_id=&cursor=` as SSE. Reads from `turnStream.cursor()`, feeds every `event: turn` frame in, and emits the ones the stream accepted. Completes when the server closes the delivery; unsubscribing aborts the request. |
|
|
682
|
+
| `cancel(requestId, reason?)` | `POST {base}turn/cancel` → `202`. Cancel is a request — call `turnStream.markCancelRequested()` and wait for the final frame. |
|
|
683
|
+
|
|
684
|
+
Notes that decide whether an adoption is correct:
|
|
685
|
+
|
|
686
|
+
- **The stream carries its own bearer.** `EventSource` cannot send an `Authorization`
|
|
687
|
+
header, so this is a `fetch` body reader. It reads the same `jwt` key the interceptor
|
|
688
|
+
reads and, on a `401`, rotates once through `BaseAuthService.refreshSession()` — the
|
|
689
|
+
shared, de-duplicated rotation — then retries. The interceptor's auth-loop circuit
|
|
690
|
+
breaker does **not** cover the stream.
|
|
691
|
+
- **Reconnect is the same call.** `stream()` re-read from `stream.cursor()` resumes
|
|
692
|
+
exactly where the last accepted frame left off. A suspension frame
|
|
693
|
+
(`approval_pending`) does not advance the cursor, so reconnecting after the approval is
|
|
694
|
+
decided picks up the resumed frame at the same sequence.
|
|
695
|
+
- **`410` is an expired replay window under a still-running turn.** No stored result
|
|
696
|
+
exists yet, which is why it is an error and not a frame. The service calls
|
|
697
|
+
`markReplayExpired()` and errors with a `ConversationStreamFailure` carrying status
|
|
698
|
+
`410`; calling `stream()` again later self-heals, because once the turn is terminal the
|
|
699
|
+
backend answers the same expired cursor with the stored final frame — delivered as an
|
|
700
|
+
ordinary `event: turn`, carrying the cursor it was asked with, so `accept()` takes it.
|
|
701
|
+
- **`event: error`** carries `ConversationStreamError` (`status`, `detail`, `request_id`) —
|
|
702
|
+
a failure raised after the stream's `200` was committed. It surfaces as the same
|
|
703
|
+
`ConversationStreamFailure`.
|
|
704
|
+
- Unknown `TurnEvent` kinds are dropped at the transport, and a frame missing
|
|
705
|
+
`request_id`, `sequence` or `events` is ignored rather than fed in.
|
|
654
706
|
|
|
655
707
|
## Agency / reseller UI (v1.1.14)
|
|
656
708
|
|
|
@@ -901,12 +953,12 @@ For backward compatibility, the older OAuth-code flow `BaseAuthService.loginWith
|
|
|
901
953
|
Horizontal building blocks for an entitlement-aware dashboard. All are presentational (signals in, events out) and ship no CSS — the app styles the documented class hooks and supplies the metric data, chart, and action logic.
|
|
902
954
|
|
|
903
955
|
- **`DashboardShellComponent`** (`sail-dashboard-shell`) — responsive card-grid layout. `[heading]` + a `[dashboardActions]` projection slot for the header; project cards into the default slot. Styles: `.dashboard-shell` / `.dashboard-header` / `.dashboard-grid`.
|
|
904
|
-
- **`DataCardComponent`** (`sail-data-card`) — card chrome for one visualization with `[state]` ∈ `loading | ready | empty | error | locked | no-source`. The chart is supplied as an `<ng-template>` and instantiated **only** in `ready`, so during loading/error/locked/no-source it never exists (no hidden canvas). `description` (required) is the screen-reader text alternative; `[tableHeaders]`/`[tableRows]` render a visually-hidden (`.sr-only`) data table. Emits `upgrade`, `connect`, and `retry`.
|
|
956
|
+
- **`DataCardComponent`** (`sail-data-card`) — card chrome for one visualization with `[state]` ∈ `loading | ready | empty | error | locked | no-source`. The chart is supplied as an `<ng-template>` and instantiated **only** in `ready`, so during loading/error/locked/no-source it never exists (no hidden canvas). `description` (required) is the screen-reader text alternative; `[tableHeaders]`/`[tableRows]` render a visually-hidden (`.sr-only`) data table. Emits `upgrade`, `connect`, and `retry`. `[connectLabel]="''"` renders the `no-source` state without a button — a source whose collector does not exist yet has nothing to connect.
|
|
905
957
|
- **`LockedOverlayComponent`** (`sail-locked-overlay`) — entitlement gate. Wraps protected content given as an `<ng-template>`; when `[locked]` the template is never instantiated, so **no data exists behind the gate** (DOM, canvas, or network) — the strict free-tier rule. Emits `upgrade`.
|
|
906
|
-
- **`NoSourceOverlayComponent`** (`sail-no-source-overlay`) — source-readiness gate. Takes `[sourceName]` and emits `connect`; while `[unavailable]`, its protected `<ng-template>` is never instantiated. Use this for included features whose provider is not connected or whose collector is unavailable—never show an upgrade prompt for this state.
|
|
958
|
+
- **`NoSourceOverlayComponent`** (`sail-no-source-overlay`) — source-readiness gate. Takes `[sourceName]` and emits `connect`; while `[unavailable]`, its protected `<ng-template>` is never instantiated. Use this for included features whose provider is not connected or whose collector is unavailable—never show an upgrade prompt for this state. Set `[ctaLabel]="''"` when there is nothing to connect, and no button renders.
|
|
907
959
|
- **`ActionCenterComponent`** (`sail-action-center`) — prioritized next-best-action list from `[actions]: ActionItem[]` (sorted by `priority`, done items show a check). Emits `act`.
|
|
908
960
|
- **`EntitySelectorComponent`** (`sail-entity-selector`) — scope dropdown over `[entities]: EntityOption[]`; resolves `[selected]` or the first active entity and emits `selectionChange`.
|
|
909
|
-
- **`VerificationFlowComponent`** (`sail-verification-flow`) — self-service request → enter-code → verified UI (a 2FA sibling), backend-agnostic: emits `requestVerification` / `confirmCode`, driven by `[step]` + `[errorMessage]` the host feeds back.
|
|
961
|
+
- **`VerificationFlowComponent`** (`sail-verification-flow`) — self-service request → enter-code → verified UI (a 2FA sibling), backend-agnostic: emits `requestVerification` / `confirmCode`, driven by `[step]` + `[errorMessage]` the host feeds back. `[resendAfterSeconds]` holds `Resend code` for a cooldown and `[codeExpiresInSeconds]` blocks `Confirm` once the issued code expires; both default to `0`, which keeps the button always enabled and shows no expiry. Both countdowns restart when the component emits `requestVerification`. `[disabled]` blocks all actions while the host is processing one. `[errorMessage]` also renders in the `idle` step, so a failed send is reported where it happened.
|
|
910
962
|
|
|
911
963
|
```html
|
|
912
964
|
<sail-data-card heading="Trends" [state]="state()" description="Weekly visibility trend" (upgrade)="goUpgrade()">
|
|
@@ -927,6 +979,106 @@ unless the backend sends
|
|
|
927
979
|
`source.status` is `null` and a missing source is indistinguishable from an
|
|
928
980
|
empty result.
|
|
929
981
|
|
|
982
|
+
## Domain verification (v1.1.22)
|
|
983
|
+
|
|
984
|
+
`DomainVerificationComponent` (`sail-domain-verification`) is the send-code → confirm
|
|
985
|
+
flow for proving ownership of a domain: the domain field, the two steps, the resend
|
|
986
|
+
cooldown, the expiry state and the errors. Every app that verifies a domain renders
|
|
987
|
+
the same thing, so only the endpoints stay downstream.
|
|
988
|
+
|
|
989
|
+
The endpoints reach it through the `DOMAIN_VERIFIER` port — sail ships no
|
|
990
|
+
implementation and knows none of the paths:
|
|
991
|
+
|
|
992
|
+
```ts
|
|
993
|
+
export interface DomainVerifier {
|
|
994
|
+
requestVerification(domain: string): Observable<unknown>;
|
|
995
|
+
confirm(domain: string, code: string): Observable<unknown>;
|
|
996
|
+
}
|
|
997
|
+
```
|
|
998
|
+
|
|
999
|
+
```ts
|
|
1000
|
+
// app config — any service with those two methods satisfies the port
|
|
1001
|
+
providers: [{ provide: DOMAIN_VERIFIER, useExisting: RegistrationService }]
|
|
1002
|
+
```
|
|
1003
|
+
|
|
1004
|
+
```html
|
|
1005
|
+
<sail-domain-verification
|
|
1006
|
+
[domain]="domainUrl()"
|
|
1007
|
+
[resendAfterSeconds]="60"
|
|
1008
|
+
[codeExpiresInSeconds]="900"
|
|
1009
|
+
(domainChange)="form.controls.domainUrl.setValue($event)"
|
|
1010
|
+
(verified)="domainVerified.set(true)" />
|
|
1011
|
+
```
|
|
1012
|
+
|
|
1013
|
+
| Input / output | Meaning |
|
|
1014
|
+
|---|---|
|
|
1015
|
+
| `[domain]` | Initial value; the component keeps its own editable copy. |
|
|
1016
|
+
| `[label]` | Field label, default `Domain`. |
|
|
1017
|
+
| `[resendAfterSeconds]` | Resend cooldown, default `60`. |
|
|
1018
|
+
| `[codeExpiresInSeconds]` | Code lifetime; `0` (default) shows no expiry. Pass the backend's TTL when it publishes one. |
|
|
1019
|
+
| `(domainChange)` | Every edit, so the host form stays in sync. |
|
|
1020
|
+
| `(verified)` | Fires once with the proven domain. |
|
|
1021
|
+
|
|
1022
|
+
Editing the domain returns the flow to `idle`: a code is bound to the domain it was
|
|
1023
|
+
issued for. The component is unstyled — style `.domain-verification`,
|
|
1024
|
+
`.domain-verification-field`, `.domain-verification-verified` and the
|
|
1025
|
+
`.verification-*` hooks of the step UI it composes.
|
|
1026
|
+
|
|
1027
|
+
## Notification inbox (v1.1.23)
|
|
1028
|
+
|
|
1029
|
+
`NotificationInboxService` is the stateful client for keel's `InboxHandler` (keel
|
|
1030
|
+
**v1.2.71+**). `NotificationCenterComponent` (`sail-notification-center`) renders it;
|
|
1031
|
+
the app handles what each message means through `(selected)`.
|
|
1032
|
+
|
|
1033
|
+
The default routes match keel. Override the three `notifications*URL` values through
|
|
1034
|
+
`configureRestUrls` only when the handler is mounted elsewhere.
|
|
1035
|
+
|
|
1036
|
+
```ts
|
|
1037
|
+
const inbox = inject(NotificationInboxService);
|
|
1038
|
+
inbox.load().subscribe(); // newest page, replaces the cache
|
|
1039
|
+
inbox.loadMore().subscribe(); // the page older than the last cached message
|
|
1040
|
+
inbox.markRead(id).subscribe();
|
|
1041
|
+
inbox.markAllRead().subscribe();
|
|
1042
|
+
inbox.reset(); // on logout — the cache is per-session
|
|
1043
|
+
```
|
|
1044
|
+
|
|
1045
|
+
```ts
|
|
1046
|
+
readonly unread = inject(NotificationInboxService).unreadCount; // Signal<number>
|
|
1047
|
+
```
|
|
1048
|
+
|
|
1049
|
+
| Member | Meaning |
|
|
1050
|
+
|---|---|
|
|
1051
|
+
| `messages` | `Signal<InboxMessage[]>`, newest first. |
|
|
1052
|
+
| `unreadCount` | `Signal<number>` across the whole inbox, not just the loaded page. |
|
|
1053
|
+
| `hasMore` | `Signal<boolean>` — the last page came back full, so older messages may exist. |
|
|
1054
|
+
| `load(limit)` / `loadMore(limit)` | Default 20; keel caps a page at 100 and the client clamps to match. |
|
|
1055
|
+
|
|
1056
|
+
Message ids are server-side `BIGINT`s serialized as strings. Both mark-read routes
|
|
1057
|
+
return `204`, so the service updates its cache locally. Call `load()` to pick up new
|
|
1058
|
+
messages and `reset()` on logout.
|
|
1059
|
+
|
|
1060
|
+
```html
|
|
1061
|
+
<sail-notification-center
|
|
1062
|
+
heading="Notifications"
|
|
1063
|
+
[pageSize]="20"
|
|
1064
|
+
(selected)="open($event)" />
|
|
1065
|
+
```
|
|
1066
|
+
|
|
1067
|
+
| Input / output | Meaning |
|
|
1068
|
+
|---|---|
|
|
1069
|
+
| `[pageSize]` | Messages per request, default 20. |
|
|
1070
|
+
| `[heading]` | Rendered as an `<h2>` with the unread count; empty (default) when the page has its own heading. |
|
|
1071
|
+
| `[emptyMessage]`, `[markAllLabel]`, `[markReadLabel]`, `[loadMoreLabel]` | Configurable labels. |
|
|
1072
|
+
| `[dateFormat]` | Angular `DatePipe` format for each timestamp, default `short`. |
|
|
1073
|
+
| `[selectable]` | `false` renders messages as static text, default `true`. |
|
|
1074
|
+
| `[markReadOnSelect]` | Selecting an unread message also marks it read, default `true`. |
|
|
1075
|
+
| `(selected)` | The chosen `InboxMessage` — route on its `type` / `data` yourself. |
|
|
1076
|
+
|
|
1077
|
+
Ships no CSS — style
|
|
1078
|
+
`.notification-center`, `.notification-center__{head,title,unread,error,loading,empty,list,more}`,
|
|
1079
|
+
`.notification-item`, `.notification-item--unread` and
|
|
1080
|
+
`.notification-item__{select,static,title,body,time,read}`.
|
|
1081
|
+
|
|
930
1082
|
## Grouped review actions and diffs (v1.1.19)
|
|
931
1083
|
|
|
932
1084
|
`GroupedBulkActionComponent` (`sail-grouped-bulk-action`) groups eligible rows
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nauticana/sail",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.23",
|
|
4
4
|
"description": "Metadata-driven Angular component library for CRUD admin frontends backed by the keel Go backend. Tables, forms, navigation, OTP/2FA/social auth, billing, payout onboarding.",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"exports": {
|
|
@@ -7,9 +7,9 @@ import { TurnStream } from '../service/turn_stream';
|
|
|
7
7
|
import { TurnEventListComponent } from './turn_event_list';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* One turn's stream: its events, its status, and a cancel request. The
|
|
11
|
-
*
|
|
12
|
-
* `stream.markCancelRequested()`. Selector: <sail-turn-stream-view>.
|
|
10
|
+
* One turn's stream: its events, its status, and a cancel request. The host owns the cancel
|
|
11
|
+
* call; on `cancelRequested` it sends it — through `ConversationService.cancel` or its own
|
|
12
|
+
* transport — and calls `stream.markCancelRequested()`. Selector: <sail-turn-stream-view>.
|
|
13
13
|
*/
|
|
14
14
|
@Component({
|
|
15
15
|
selector: 'sail-turn-stream-view',
|
package/src/agent/index.ts
CHANGED
|
@@ -2,10 +2,13 @@ export * from './model/agent_studio';
|
|
|
2
2
|
export * from './model/turn_event';
|
|
3
3
|
export * from './model/reply_frame';
|
|
4
4
|
export * from './model/decision';
|
|
5
|
+
export * from './model/conversation';
|
|
5
6
|
export * from './service/agent_studio.service';
|
|
6
7
|
export * from './service/turn_stream';
|
|
8
|
+
export * from './service/conversation.service';
|
|
7
9
|
export * from './util/studio_errors';
|
|
8
10
|
export * from './util/turn_timeline';
|
|
11
|
+
export * from './util/sse';
|
|
9
12
|
export * from './component/prompt_section_editor';
|
|
10
13
|
export * from './component/prompt_language_editor';
|
|
11
14
|
export * from './component/release_sections';
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { InjectionToken } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
/** One asynchronous turn, as Scout's conversation bridge takes it. */
|
|
4
|
+
export interface ConversationTurnRequest {
|
|
5
|
+
request_id: string;
|
|
6
|
+
conversation_id: string;
|
|
7
|
+
agent_id: string;
|
|
8
|
+
/** Opaque product payload; the backend forwards it to the agent unread. */
|
|
9
|
+
input: unknown;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ConversationTurnAccepted {
|
|
13
|
+
request_id: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Data of an SSE `error` event: a failure raised after the stream's 200 was committed. */
|
|
17
|
+
export interface ConversationStreamError {
|
|
18
|
+
status: number;
|
|
19
|
+
detail: string;
|
|
20
|
+
request_id?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Error thrown into `ConversationService.stream()`; status 410 is an expired replay window. */
|
|
24
|
+
export class ConversationStreamFailure extends Error {
|
|
25
|
+
constructor(readonly status: number, readonly detail: string, readonly requestId?: string) {
|
|
26
|
+
super(detail);
|
|
27
|
+
this.name = 'ConversationStreamFailure';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Where the app mounts Scout's ConversationHandler, e.g. '/api/wingmate/'. */
|
|
32
|
+
export const CONVERSATION_BASE_PATH = new InjectionToken<string>('SailConversationBasePath', {
|
|
33
|
+
providedIn: 'root',
|
|
34
|
+
factory: () => '/api/conversation/',
|
|
35
|
+
});
|
|
@@ -3,8 +3,8 @@ import { TurnEvent } from './turn_event';
|
|
|
3
3
|
export const TURN_ERROR_CANCELED = 'canceled';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* One reply frame as sail consumes it.
|
|
7
|
-
* app
|
|
6
|
+
* One reply frame as sail consumes it. `ConversationService` validates Scout's standard wire
|
|
7
|
+
* shape; app-specific transports can map their own reply shape onto this one.
|
|
8
8
|
*/
|
|
9
9
|
export interface TurnReplyFrame {
|
|
10
10
|
request_id: string;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { Injectable, inject } from '@angular/core';
|
|
2
|
+
import { Observable, Subscriber, firstValueFrom } from 'rxjs';
|
|
3
|
+
import { BaseAuthService } from '../../service/auth.service';
|
|
4
|
+
import { BaseRestService } from '../../service/base_rest.service';
|
|
5
|
+
import {
|
|
6
|
+
CONVERSATION_BASE_PATH,
|
|
7
|
+
ConversationStreamError,
|
|
8
|
+
ConversationStreamFailure,
|
|
9
|
+
ConversationTurnAccepted,
|
|
10
|
+
ConversationTurnRequest,
|
|
11
|
+
} from '../model/conversation';
|
|
12
|
+
import { TurnReplyFrame } from '../model/reply_frame';
|
|
13
|
+
import { isKnownTurnEvent } from '../model/turn_event';
|
|
14
|
+
import { SseParser, ServerSentEvent } from '../util/sse';
|
|
15
|
+
import { TurnStream } from './turn_stream';
|
|
16
|
+
|
|
17
|
+
const REPLAY_EXPIRED = 410;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Transport for Scout's conversation bridge: `POST {base}turn`,
|
|
21
|
+
* `GET {base}turn/stream` as SSE, `POST {base}turn/cancel`. Submit and cancel go through
|
|
22
|
+
* `HttpClient` (and so through sail's interceptors); the stream cannot, because
|
|
23
|
+
* `EventSource` sends no `Authorization` header — it is a `fetch` body reader that carries
|
|
24
|
+
* the bearer itself and rotates it through `BaseAuthService.refreshSession()` once on a 401.
|
|
25
|
+
*
|
|
26
|
+
* The interceptor's auth-loop circuit breaker does not cover the stream.
|
|
27
|
+
*/
|
|
28
|
+
@Injectable({ providedIn: 'root' })
|
|
29
|
+
export class ConversationService extends BaseRestService {
|
|
30
|
+
private readonly auth = inject(BaseAuthService, { optional: true });
|
|
31
|
+
private readonly basePath = normalizeBasePath(inject(CONVERSATION_BASE_PATH));
|
|
32
|
+
|
|
33
|
+
submit(request: ConversationTurnRequest): Observable<ConversationTurnAccepted> {
|
|
34
|
+
return this.http.post<ConversationTurnAccepted>(this.conversationUrl('turn'), request);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Cancel is a request: the turn stays open until its final frame arrives. */
|
|
38
|
+
cancel(requestId: string, reason?: string): Observable<ConversationTurnAccepted> {
|
|
39
|
+
return this.http.post<ConversationTurnAccepted>(this.conversationUrl('turn/cancel'), {
|
|
40
|
+
request_id: requestId,
|
|
41
|
+
reason,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Read the reply stream from `stream.cursor()` into `stream`, emitting every accepted frame
|
|
47
|
+
* and completing when the server closes the delivery. Unsubscribing aborts the request; a
|
|
48
|
+
* reconnect — after a drop or a resolved approval — calls this again unchanged.
|
|
49
|
+
*/
|
|
50
|
+
stream(requestId: string, stream: TurnStream): Observable<TurnReplyFrame> {
|
|
51
|
+
return new Observable<TurnReplyFrame>((subscriber) => {
|
|
52
|
+
const controller = new AbortController();
|
|
53
|
+
this.read(requestId, stream, controller.signal, subscriber).then(
|
|
54
|
+
() => subscriber.complete(),
|
|
55
|
+
(err: unknown) => { if (!controller.signal.aborted) subscriber.error(err); },
|
|
56
|
+
);
|
|
57
|
+
return () => controller.abort();
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private async read(
|
|
62
|
+
requestId: string,
|
|
63
|
+
stream: TurnStream,
|
|
64
|
+
signal: AbortSignal,
|
|
65
|
+
subscriber: Subscriber<TurnReplyFrame>,
|
|
66
|
+
): Promise<void> {
|
|
67
|
+
let response = await this.open(requestId, stream.cursor(), signal, localStorage.getItem('jwt'));
|
|
68
|
+
if (response.status === 401 && this.auth) {
|
|
69
|
+
const token = await firstValueFrom(this.auth.refreshSession());
|
|
70
|
+
response = await this.open(requestId, stream.cursor(), signal, token);
|
|
71
|
+
}
|
|
72
|
+
if (response.status === REPLAY_EXPIRED) {
|
|
73
|
+
// The window is gone and the turn has not finished, so no stored result exists yet;
|
|
74
|
+
// streaming again once it is terminal delivers that result instead.
|
|
75
|
+
stream.markReplayExpired();
|
|
76
|
+
throw new ConversationStreamFailure(REPLAY_EXPIRED, await failureDetail(response), requestId);
|
|
77
|
+
}
|
|
78
|
+
if (!response.ok || !response.body) {
|
|
79
|
+
throw new ConversationStreamFailure(response.status, await failureDetail(response), requestId);
|
|
80
|
+
}
|
|
81
|
+
// A 200 that is not the event stream (a proxy's error page, a login redirect) would
|
|
82
|
+
// otherwise parse to nothing and complete the turn silently.
|
|
83
|
+
if (!response.headers.get('Content-Type')?.includes('text/event-stream')) {
|
|
84
|
+
throw new ConversationStreamFailure(response.status, 'conversation stream did not return text/event-stream', requestId);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const reader = response.body.getReader();
|
|
88
|
+
const decoder = new TextDecoder();
|
|
89
|
+
const parser = new SseParser();
|
|
90
|
+
for (;;) {
|
|
91
|
+
const { done, value } = await reader.read();
|
|
92
|
+
if (done) {
|
|
93
|
+
for (const event of parser.push(decoder.decode())) {
|
|
94
|
+
this.dispatch(event, requestId, stream, subscriber);
|
|
95
|
+
}
|
|
96
|
+
for (const event of parser.finish()) {
|
|
97
|
+
this.dispatch(event, requestId, stream, subscriber);
|
|
98
|
+
}
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
for (const event of parser.push(decoder.decode(value, { stream: true }))) {
|
|
102
|
+
this.dispatch(event, requestId, stream, subscriber);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private dispatch(
|
|
108
|
+
event: ServerSentEvent,
|
|
109
|
+
requestId: string,
|
|
110
|
+
stream: TurnStream,
|
|
111
|
+
subscriber: Subscriber<TurnReplyFrame>,
|
|
112
|
+
): void {
|
|
113
|
+
if (event.event === 'error') {
|
|
114
|
+
const failure = parseJson<ConversationStreamError>(event.data);
|
|
115
|
+
const status = failure?.status ?? 0;
|
|
116
|
+
if (status === REPLAY_EXPIRED) stream.markReplayExpired();
|
|
117
|
+
throw new ConversationStreamFailure(
|
|
118
|
+
status,
|
|
119
|
+
failure?.detail ?? 'conversation stream failed',
|
|
120
|
+
failure?.request_id ?? requestId,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
if (event.event !== 'turn') return;
|
|
124
|
+
const frame = toFrame(parseJson(event.data));
|
|
125
|
+
// A stored result answering an expired cursor arrives on this channel too, carrying the
|
|
126
|
+
// sequence it was asked with, so accept() takes it like any other frame.
|
|
127
|
+
if (frame && stream.accept(frame)) subscriber.next(frame);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private open(requestId: string, cursor: number, signal: AbortSignal, token: string | null): Promise<Response> {
|
|
131
|
+
const query = new URLSearchParams({ request_id: requestId, cursor: String(cursor) });
|
|
132
|
+
return fetch(`${this.conversationUrl('turn/stream')}?${query}`, {
|
|
133
|
+
signal,
|
|
134
|
+
headers: {
|
|
135
|
+
Accept: 'text/event-stream',
|
|
136
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private conversationUrl(path: string): string {
|
|
142
|
+
return this.url(this.basePath + path);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function normalizeBasePath(base: string): string {
|
|
147
|
+
return '/' + base.trim().replace(/^\/+|\/+$/g, '') + '/';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function parseJson<T>(raw: string): T | undefined {
|
|
151
|
+
try {
|
|
152
|
+
return JSON.parse(raw) as T;
|
|
153
|
+
} catch {
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Unknown event kinds are dropped here so `TurnStream.events()` stays typed as it reads. */
|
|
159
|
+
function toFrame(value: unknown): TurnReplyFrame | undefined {
|
|
160
|
+
if (!value || typeof value !== 'object') return undefined;
|
|
161
|
+
const frame = value as Partial<TurnReplyFrame>;
|
|
162
|
+
if (typeof frame.request_id !== 'string' || typeof frame.sequence !== 'number' || !Array.isArray(frame.events)) {
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
...frame,
|
|
167
|
+
request_id: frame.request_id,
|
|
168
|
+
conversation_id: typeof frame.conversation_id === 'string' ? frame.conversation_id : '',
|
|
169
|
+
sequence: frame.sequence,
|
|
170
|
+
events: frame.events.filter(isKnownTurnEvent),
|
|
171
|
+
final: frame.final === true,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function failureDetail(response: Response): Promise<string> {
|
|
176
|
+
const body = await response.text().catch(() => '');
|
|
177
|
+
const problem = parseJson<{ detail?: string; title?: string; error?: string }>(body);
|
|
178
|
+
const detail = problem?.detail ?? problem?.title ?? problem?.error ?? body;
|
|
179
|
+
return detail || `HTTP ${response.status}`;
|
|
180
|
+
}
|
|
@@ -5,9 +5,9 @@ import { TurnEvent } from '../model/turn_event';
|
|
|
5
5
|
const terminalStatuses: ReadonlySet<TurnStreamStatus> = new Set(['completed', 'canceled', 'failed']);
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* Client-side state of one turn's reply stream.
|
|
9
|
-
* `accept`, reconnects from `cursor()`, and reports an expired replay
|
|
10
|
-
* turn; not injectable.
|
|
8
|
+
* Client-side state of one turn's reply stream. `ConversationService` or an app-specific
|
|
9
|
+
* transport feeds frames to `accept`, reconnects from `cursor()`, and reports an expired replay
|
|
10
|
+
* window. One instance per turn; not injectable.
|
|
11
11
|
*/
|
|
12
12
|
export class TurnStream {
|
|
13
13
|
private readonly frames = signal<TurnReplyFrame[]>([]);
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export interface ServerSentEvent {
|
|
2
|
+
event: string;
|
|
3
|
+
data: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Incremental `text/event-stream` parser. A fetch body reader hands it decoded chunks —
|
|
8
|
+
* which split anywhere — and it returns only whole events.
|
|
9
|
+
*/
|
|
10
|
+
export class SseParser {
|
|
11
|
+
private buffer = '';
|
|
12
|
+
private pendingCarriageReturn = false;
|
|
13
|
+
|
|
14
|
+
push(chunk: string): ServerSentEvent[] {
|
|
15
|
+
// An empty chunk must not resolve a pending CR: the LF completing it may still be coming.
|
|
16
|
+
if (!chunk) return [];
|
|
17
|
+
if (this.pendingCarriageReturn) {
|
|
18
|
+
this.buffer += '\n';
|
|
19
|
+
this.pendingCarriageReturn = false;
|
|
20
|
+
if (chunk.startsWith('\n')) chunk = chunk.slice(1);
|
|
21
|
+
}
|
|
22
|
+
if (chunk.endsWith('\r')) {
|
|
23
|
+
this.pendingCarriageReturn = true;
|
|
24
|
+
chunk = chunk.slice(0, -1);
|
|
25
|
+
}
|
|
26
|
+
this.buffer += chunk.replace(/\r\n?/g, '\n');
|
|
27
|
+
return this.drain();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Resolve a final standalone carriage return when the response body closes. */
|
|
31
|
+
finish(): ServerSentEvent[] {
|
|
32
|
+
if (this.pendingCarriageReturn) {
|
|
33
|
+
this.buffer += '\n';
|
|
34
|
+
this.pendingCarriageReturn = false;
|
|
35
|
+
}
|
|
36
|
+
return this.drain();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private drain(): ServerSentEvent[] {
|
|
40
|
+
const events: ServerSentEvent[] = [];
|
|
41
|
+
for (let end = this.buffer.indexOf('\n\n'); end !== -1; end = this.buffer.indexOf('\n\n')) {
|
|
42
|
+
const event = parseBlock(this.buffer.slice(0, end));
|
|
43
|
+
this.buffer = this.buffer.slice(end + 2);
|
|
44
|
+
if (event) events.push(event);
|
|
45
|
+
}
|
|
46
|
+
return events;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseBlock(block: string): ServerSentEvent | undefined {
|
|
51
|
+
let event = 'message';
|
|
52
|
+
const data: string[] = [];
|
|
53
|
+
for (const line of block.split(/\r\n|\r|\n/)) {
|
|
54
|
+
if (!line || line.startsWith(':')) continue;
|
|
55
|
+
const colon = line.indexOf(':');
|
|
56
|
+
const field = colon === -1 ? line : line.slice(0, colon);
|
|
57
|
+
const raw = colon === -1 ? '' : line.slice(colon + 1);
|
|
58
|
+
const value = raw.startsWith(' ') ? raw.slice(1) : raw;
|
|
59
|
+
if (field === 'event') event = value;
|
|
60
|
+
else if (field === 'data') data.push(value);
|
|
61
|
+
}
|
|
62
|
+
return data.length ? { event, data: data.join('\n') } : undefined;
|
|
63
|
+
}
|
|
@@ -37,7 +37,9 @@
|
|
|
37
37
|
<div class="data-card-no-source" role="group" [attr.aria-label]="(heading() || 'Insight') + ' — source unavailable'">
|
|
38
38
|
<p class="no-source-name">{{ sourceName() }}</p>
|
|
39
39
|
<p class="no-source-message">{{ noSourceMessage() }}</p>
|
|
40
|
-
|
|
40
|
+
@if (connectLabel()) {
|
|
41
|
+
<button matButton="elevated" type="button" class="primary" (click)="connect.emit()">{{ connectLabel() }}</button>
|
|
42
|
+
}
|
|
41
43
|
</div>
|
|
42
44
|
}
|
|
43
45
|
}
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
<div class="no-source-overlay" role="group" [attr.aria-label]="sourceName() + ' — source unavailable'">
|
|
3
3
|
<p class="no-source-name">{{ sourceName() }}</p>
|
|
4
4
|
<p class="no-source-message">{{ message() }}</p>
|
|
5
|
-
|
|
5
|
+
@if (ctaLabel()) {
|
|
6
|
+
<button matButton="elevated" type="button" class="primary" (click)="connect.emit()">{{ ctaLabel() }}</button>
|
|
7
|
+
}
|
|
6
8
|
</div>
|
|
7
9
|
} @else if (content(); as tpl) {
|
|
8
10
|
<ng-container [ngTemplateOutlet]="tpl"></ng-container>
|
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
<p class="verification-intro">
|
|
8
8
|
Verify ownership@if (target()) { of {{ target() }} } to unlock trusted features.
|
|
9
9
|
</p>
|
|
10
|
-
<
|
|
10
|
+
@if (errorMessage()) { <p class="verification-error" role="alert">{{ errorMessage() }}</p> }
|
|
11
|
+
<button matButton="elevated" type="button" class="primary"
|
|
12
|
+
[disabled]="disabled()" (click)="request()">
|
|
11
13
|
Start verification
|
|
12
14
|
</button>
|
|
13
15
|
}
|
|
@@ -17,15 +19,26 @@
|
|
|
17
19
|
<mat-label>Verification code</mat-label>
|
|
18
20
|
<input matInput #codeInput name="code" autocomplete="one-time-code"
|
|
19
21
|
[value]="code()" (input)="code.set(codeInput.value)"
|
|
20
|
-
[disabled]="step() === 'verifying'">
|
|
22
|
+
[disabled]="disabled() || step() === 'verifying'">
|
|
21
23
|
</mat-form-field>
|
|
22
24
|
@if (errorMessage()) { <p class="verification-error" role="alert">{{ errorMessage() }}</p> }
|
|
25
|
+
@if (expired()) {
|
|
26
|
+
<p class="verification-expiry verification-expiry--expired" role="status">
|
|
27
|
+
That code expired. Request a new one.
|
|
28
|
+
</p>
|
|
29
|
+
} @else if (expiresIn() > 0) {
|
|
30
|
+
<p class="verification-expiry" aria-live="polite">Code expires in {{ expiresIn() }}s.</p>
|
|
31
|
+
}
|
|
23
32
|
<div class="verification-actions">
|
|
24
33
|
<button matButton="elevated" type="button" class="primary"
|
|
25
|
-
[disabled]="step() === 'verifying' || !code().trim()" (click)="submit()">
|
|
34
|
+
[disabled]="disabled() || step() === 'verifying' || !code().trim() || expired()" (click)="submit()">
|
|
26
35
|
@if (step() === 'verifying') { Verifying… } @else { Confirm }
|
|
27
36
|
</button>
|
|
28
|
-
|
|
37
|
+
@if (resendIn() > 0) {
|
|
38
|
+
<span class="verification-resend" aria-live="polite">Resend code in {{ resendIn() }}s</span>
|
|
39
|
+
} @else {
|
|
40
|
+
<button matButton type="button" [disabled]="disabled()" (click)="request()">Resend code</button>
|
|
41
|
+
}
|
|
29
42
|
</div>
|
|
30
43
|
}
|
|
31
44
|
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
ChangeDetectionStrategy, Component, DestroyRef, ViewEncapsulation,
|
|
3
|
+
inject, input, output, signal,
|
|
4
|
+
} from '@angular/core';
|
|
2
5
|
import { MatButtonModule } from '@angular/material/button';
|
|
3
6
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
4
7
|
import { MatInputModule } from '@angular/material/input';
|
|
@@ -18,19 +21,71 @@ import { VerificationStep } from '../../model/dashboard';
|
|
|
18
21
|
imports: [MatButtonModule, MatFormFieldModule, MatInputModule],
|
|
19
22
|
})
|
|
20
23
|
export class VerificationFlowComponent {
|
|
24
|
+
private readonly destroyRef = inject(DestroyRef);
|
|
25
|
+
|
|
21
26
|
readonly step = input<VerificationStep>('idle');
|
|
22
27
|
readonly errorMessage = input<string>('');
|
|
23
28
|
/** The email/phone/identifier being verified, shown in the copy. */
|
|
24
29
|
readonly target = input<string>('');
|
|
30
|
+
/** Seconds before `Resend code` re-enables; 0 leaves it always enabled. */
|
|
31
|
+
readonly resendAfterSeconds = input(0);
|
|
32
|
+
/** Seconds the issued code stays valid; 0 shows no expiry and never blocks confirm. */
|
|
33
|
+
readonly codeExpiresInSeconds = input(0);
|
|
34
|
+
/** Prevent interaction while the host is processing a request. */
|
|
35
|
+
readonly disabled = input(false);
|
|
36
|
+
|
|
25
37
|
readonly requestVerification = output<void>();
|
|
26
38
|
readonly confirmCode = output<string>();
|
|
27
39
|
|
|
28
40
|
readonly code = signal('');
|
|
41
|
+
readonly resendIn = signal(0);
|
|
42
|
+
readonly expiresIn = signal(0);
|
|
43
|
+
readonly expired = signal(false);
|
|
44
|
+
|
|
45
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
46
|
+
|
|
47
|
+
constructor() {
|
|
48
|
+
this.destroyRef.onDestroy(() => this.clearTimer());
|
|
49
|
+
}
|
|
29
50
|
|
|
30
51
|
submit(): void {
|
|
31
52
|
const c = this.code().trim();
|
|
32
|
-
if (c) {
|
|
53
|
+
if (c && !this.expired() && !this.disabled()) {
|
|
33
54
|
this.confirmCode.emit(c);
|
|
34
55
|
}
|
|
35
56
|
}
|
|
57
|
+
|
|
58
|
+
/** Both the first request and a resend restart the countdowns, as the OTP input does. */
|
|
59
|
+
request(): void {
|
|
60
|
+
if (this.disabled()) return;
|
|
61
|
+
this.requestVerification.emit();
|
|
62
|
+
this.startCountdowns();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private startCountdowns(): void {
|
|
66
|
+
this.clearTimer();
|
|
67
|
+
this.code.set('');
|
|
68
|
+
this.expired.set(false);
|
|
69
|
+
this.resendIn.set(this.resendAfterSeconds());
|
|
70
|
+
this.expiresIn.set(this.codeExpiresInSeconds());
|
|
71
|
+
if (this.resendIn() > 0 || this.expiresIn() > 0) {
|
|
72
|
+
this.timer = setInterval(() => this.tick(), 1000);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private tick(): void {
|
|
77
|
+
if (this.resendIn() > 0) this.resendIn.update((s) => s - 1);
|
|
78
|
+
if (this.expiresIn() > 0) {
|
|
79
|
+
this.expiresIn.update((s) => s - 1);
|
|
80
|
+
if (this.expiresIn() === 0) this.expired.set(true);
|
|
81
|
+
}
|
|
82
|
+
if (this.resendIn() === 0 && this.expiresIn() === 0) this.clearTimer();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private clearTimer(): void {
|
|
86
|
+
if (this.timer) {
|
|
87
|
+
clearInterval(this.timer);
|
|
88
|
+
this.timer = null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
36
91
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
<section class="notification-center" [attr.aria-busy]="loading()">
|
|
2
|
+
<div class="notification-center__head">
|
|
3
|
+
@if (heading()) {
|
|
4
|
+
<h2 class="notification-center__title">
|
|
5
|
+
{{ heading() }}
|
|
6
|
+
@if (unreadCount()) { <span class="notification-center__unread">{{ unreadCount() }}</span> }
|
|
7
|
+
</h2>
|
|
8
|
+
}
|
|
9
|
+
@if (unreadCount()) {
|
|
10
|
+
<button matButton type="button" [disabled]="loading()" (click)="markAllRead()">{{ markAllLabel() }}</button>
|
|
11
|
+
}
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
@if (errorMessage()) {
|
|
15
|
+
<p class="notification-center__error" role="alert">{{ errorMessage() }}</p>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
@if (loading() && !messages().length) {
|
|
19
|
+
<div class="notification-center__loading">
|
|
20
|
+
<mat-spinner diameter="28" aria-label="Loading notifications"></mat-spinner>
|
|
21
|
+
</div>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@if (empty()) {
|
|
25
|
+
<p class="notification-center__empty">{{ emptyMessage() }}</p>
|
|
26
|
+
} @else if (messages().length) {
|
|
27
|
+
<ul class="notification-center__list">
|
|
28
|
+
@for (message of messages(); track message.id) {
|
|
29
|
+
<li class="notification-item" [class.notification-item--unread]="!message.readAt">
|
|
30
|
+
@if (selectable()) {
|
|
31
|
+
<button class="notification-item__select" type="button" [disabled]="loading()" (click)="select(message)">
|
|
32
|
+
<span class="notification-item__title">{{ message.title }}</span>
|
|
33
|
+
<span class="notification-item__body">{{ message.body }}</span>
|
|
34
|
+
<time class="notification-item__time" [attr.datetime]="message.createdAt">{{ message.createdAt | date: dateFormat() }}</time>
|
|
35
|
+
</button>
|
|
36
|
+
} @else {
|
|
37
|
+
<div class="notification-item__static">
|
|
38
|
+
<span class="notification-item__title">{{ message.title }}</span>
|
|
39
|
+
<span class="notification-item__body">{{ message.body }}</span>
|
|
40
|
+
<time class="notification-item__time" [attr.datetime]="message.createdAt">{{ message.createdAt | date: dateFormat() }}</time>
|
|
41
|
+
</div>
|
|
42
|
+
}
|
|
43
|
+
@if (!message.readAt) {
|
|
44
|
+
<button matIconButton type="button" class="notification-item__read"
|
|
45
|
+
[attr.aria-label]="markReadLabel() + ': ' + message.title"
|
|
46
|
+
[disabled]="loading()" (click)="markRead(message)">
|
|
47
|
+
<mat-icon>done</mat-icon>
|
|
48
|
+
</button>
|
|
49
|
+
}
|
|
50
|
+
</li>
|
|
51
|
+
}
|
|
52
|
+
</ul>
|
|
53
|
+
|
|
54
|
+
@if (hasMore()) {
|
|
55
|
+
<button matButton type="button" class="notification-center__more" [disabled]="loading()" (click)="loadMore()">
|
|
56
|
+
{{ loadMoreLabel() }}
|
|
57
|
+
</button>
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
</section>
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, OnInit, ViewEncapsulation, computed, inject, input, output } from '@angular/core';
|
|
2
|
+
import { DatePipe } from '@angular/common';
|
|
3
|
+
import { MatButtonModule } from '@angular/material/button';
|
|
4
|
+
import { MatIconModule } from '@angular/material/icon';
|
|
5
|
+
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
|
6
|
+
import { BaseAsync } from '../abstract/base_async';
|
|
7
|
+
import { NotificationInboxService, INBOX_DEFAULT_PAGE_SIZE } from '../../service/notification_inbox.service';
|
|
8
|
+
import { InboxMessage } from '../../model/notification';
|
|
9
|
+
|
|
10
|
+
/** In-app inbox over keel's `InboxHandler`; message routing stays with the app. */
|
|
11
|
+
@Component({
|
|
12
|
+
selector: 'sail-notification-center',
|
|
13
|
+
templateUrl: './notification_center.html',
|
|
14
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
15
|
+
encapsulation: ViewEncapsulation.None,
|
|
16
|
+
imports: [DatePipe, MatButtonModule, MatIconModule, MatProgressSpinnerModule],
|
|
17
|
+
})
|
|
18
|
+
export class NotificationCenterComponent extends BaseAsync implements OnInit {
|
|
19
|
+
readonly pageSize = input(INBOX_DEFAULT_PAGE_SIZE);
|
|
20
|
+
readonly heading = input('');
|
|
21
|
+
readonly emptyMessage = input('You have no notifications.');
|
|
22
|
+
readonly markAllLabel = input('Mark all as read');
|
|
23
|
+
readonly markReadLabel = input('Mark as read');
|
|
24
|
+
readonly loadMoreLabel = input('Load older');
|
|
25
|
+
readonly dateFormat = input('short');
|
|
26
|
+
readonly selectable = input(true);
|
|
27
|
+
readonly markReadOnSelect = input(true);
|
|
28
|
+
|
|
29
|
+
readonly selected = output<InboxMessage>();
|
|
30
|
+
|
|
31
|
+
private readonly inbox = inject(NotificationInboxService);
|
|
32
|
+
|
|
33
|
+
readonly messages = this.inbox.messages;
|
|
34
|
+
readonly unreadCount = this.inbox.unreadCount;
|
|
35
|
+
readonly hasMore = this.inbox.hasMore;
|
|
36
|
+
readonly empty = computed(() => !this.loading() && !this.errorMessage() && this.messages().length === 0);
|
|
37
|
+
|
|
38
|
+
ngOnInit(): void {
|
|
39
|
+
this.reload();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
reload(): void {
|
|
43
|
+
this.run(this.inbox.load(this.pageSize()), () => undefined, 'Could not load your notifications.');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
loadMore(): void {
|
|
47
|
+
this.run(this.inbox.loadMore(this.pageSize()), () => undefined, 'Could not load older notifications.');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
select(message: InboxMessage): void {
|
|
51
|
+
// Not via run(): the app may navigate away on select, and teardown would cancel the POST.
|
|
52
|
+
if (this.markReadOnSelect() && !message.readAt) {
|
|
53
|
+
this.inbox.markRead(message.id).subscribe({
|
|
54
|
+
error: (err) => this.setError(err, 'Could not mark that notification read.'),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
this.selected.emit(message);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
markRead(message: InboxMessage): void {
|
|
61
|
+
if (message.readAt) return;
|
|
62
|
+
this.run(this.inbox.markRead(message.id), () => undefined, 'Could not mark that notification read.');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
markAllRead(): void {
|
|
66
|
+
if (!this.unreadCount()) return;
|
|
67
|
+
this.run(this.inbox.markAllRead(), () => undefined, 'Could not mark your notifications read.');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<div class="domain-verification" [attr.data-step]="step()">
|
|
2
|
+
<div class="domain-verification-field">
|
|
3
|
+
<mat-form-field appearance="outline">
|
|
4
|
+
<mat-label>{{ label() }}</mat-label>
|
|
5
|
+
<input matInput #domainField type="text" name="domain" inputmode="url" autocomplete="url"
|
|
6
|
+
[value]="domain()" (input)="onDomainInput(domainField.value)"
|
|
7
|
+
[disabled]="isVerified() || loading()">
|
|
8
|
+
</mat-form-field>
|
|
9
|
+
@if (isVerified()) {
|
|
10
|
+
<mat-icon class="domain-verification-verified" aria-hidden="true">verified</mat-icon>
|
|
11
|
+
}
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
<sail-verification-flow
|
|
15
|
+
[step]="step()"
|
|
16
|
+
[target]="domain()"
|
|
17
|
+
[errorMessage]="errorMessage()"
|
|
18
|
+
[disabled]="loading() || !domain().trim()"
|
|
19
|
+
[resendAfterSeconds]="resendAfterSeconds()"
|
|
20
|
+
[codeExpiresInSeconds]="codeExpiresInSeconds()"
|
|
21
|
+
(requestVerification)="request()"
|
|
22
|
+
(confirmCode)="confirm($event)" />
|
|
23
|
+
</div>
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChangeDetectionStrategy, Component, ViewEncapsulation,
|
|
3
|
+
computed, effect, inject, input, output, signal, untracked,
|
|
4
|
+
} from '@angular/core';
|
|
5
|
+
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
6
|
+
import { MatIconModule } from '@angular/material/icon';
|
|
7
|
+
import { MatInputModule } from '@angular/material/input';
|
|
8
|
+
import { VerificationStep } from '../../model/dashboard';
|
|
9
|
+
import { DOMAIN_VERIFIER } from '../../service/domain_verifier';
|
|
10
|
+
import { BaseAsync } from '../abstract/base_async';
|
|
11
|
+
import { VerificationFlowComponent } from '../dashboard/verification_flow';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Domain-ownership verification: the domain field plus the send-code → confirm steps,
|
|
15
|
+
* driven against the injected `DOMAIN_VERIFIER`. The app owns the endpoints, the page
|
|
16
|
+
* shell and the styling; `verified` fires once with the domain that was proven.
|
|
17
|
+
*
|
|
18
|
+
* Selector: <sail-domain-verification>
|
|
19
|
+
*/
|
|
20
|
+
@Component({
|
|
21
|
+
selector: 'sail-domain-verification',
|
|
22
|
+
templateUrl: './domain_verification.html',
|
|
23
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
24
|
+
encapsulation: ViewEncapsulation.None,
|
|
25
|
+
imports: [MatFormFieldModule, MatIconModule, MatInputModule, VerificationFlowComponent],
|
|
26
|
+
})
|
|
27
|
+
export class DomainVerificationComponent extends BaseAsync {
|
|
28
|
+
private readonly verifier = inject(DOMAIN_VERIFIER);
|
|
29
|
+
|
|
30
|
+
readonly domainInput = input('', { alias: 'domain' });
|
|
31
|
+
readonly label = input('Domain');
|
|
32
|
+
readonly resendAfterSeconds = input(60);
|
|
33
|
+
readonly codeExpiresInSeconds = input(0);
|
|
34
|
+
|
|
35
|
+
readonly domainChange = output<string>();
|
|
36
|
+
readonly verified = output<string>();
|
|
37
|
+
|
|
38
|
+
readonly domain = signal('');
|
|
39
|
+
readonly step = signal<VerificationStep>('idle');
|
|
40
|
+
readonly isVerified = computed(() => this.step() === 'verified');
|
|
41
|
+
|
|
42
|
+
constructor() {
|
|
43
|
+
super();
|
|
44
|
+
effect(() => {
|
|
45
|
+
const value = this.domainInput();
|
|
46
|
+
if (value === untracked(this.domain)) return;
|
|
47
|
+
this.domain.set(value);
|
|
48
|
+
this.step.set('idle');
|
|
49
|
+
this.clearMessages();
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
protected onDomainInput(value: string): void {
|
|
54
|
+
this.domain.set(value);
|
|
55
|
+
this.step.set('idle'); // a code is bound to the domain it was issued for
|
|
56
|
+
this.clearMessages();
|
|
57
|
+
this.domainChange.emit(value);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
protected request(): void {
|
|
61
|
+
const domain = this.domain().trim();
|
|
62
|
+
if (!domain || this.isVerified() || this.loading()) return;
|
|
63
|
+
this.run(
|
|
64
|
+
this.verifier.requestVerification(domain),
|
|
65
|
+
() => this.step.set('requested'),
|
|
66
|
+
'Could not send the verification code.',
|
|
67
|
+
() => this.step.set('idle'),
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
protected confirm(code: string): void {
|
|
72
|
+
const domain = this.domain().trim();
|
|
73
|
+
if (!domain || this.isVerified() || this.loading()) return;
|
|
74
|
+
this.step.set('verifying');
|
|
75
|
+
this.run(
|
|
76
|
+
this.verifier.confirm(domain, code),
|
|
77
|
+
() => {
|
|
78
|
+
this.step.set('verified');
|
|
79
|
+
this.verified.emit(domain);
|
|
80
|
+
},
|
|
81
|
+
'Verification failed.',
|
|
82
|
+
() => this.step.set('error'),
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ export * from './model/auth';
|
|
|
4
4
|
export * from './model/common';
|
|
5
5
|
export * from './model/dashboard';
|
|
6
6
|
export * from './model/oauth_connection';
|
|
7
|
+
export * from './model/notification';
|
|
7
8
|
export * from './model/agency';
|
|
8
9
|
export * from './model/decorator';
|
|
9
10
|
export * from './component/abstract/base_table';
|
|
@@ -14,6 +15,8 @@ export * from './component/dashboard/no_source_overlay';
|
|
|
14
15
|
export * from './component/dashboard/action_center';
|
|
15
16
|
export * from './component/dashboard/entity_selector';
|
|
16
17
|
export * from './component/dashboard/verification_flow';
|
|
18
|
+
export * from './component/verification/domain_verification';
|
|
19
|
+
export * from './component/notification/notification_center';
|
|
17
20
|
export * from './component/oauth_connection/oauth_connection_panel';
|
|
18
21
|
export * from './component/abstract/base_form';
|
|
19
22
|
export * from './component/abstract/base_view';
|
|
@@ -68,10 +71,12 @@ export * from './component/payment/user_payment_methods';
|
|
|
68
71
|
export * from './component/payment/sca_confirm';
|
|
69
72
|
export * from './service/auth.service';
|
|
70
73
|
export * from './service/sca_confirmer';
|
|
74
|
+
export * from './service/domain_verifier';
|
|
71
75
|
export * from './service/billing.service';
|
|
72
76
|
export * from './service/payout.service';
|
|
73
77
|
export * from './service/agency.service';
|
|
74
78
|
export * from './service/oauth_connection.service';
|
|
79
|
+
export * from './service/notification_inbox.service';
|
|
75
80
|
export * from './service/user_payment_method.service';
|
|
76
81
|
export * from './service/script_loader';
|
|
77
82
|
export * from './service/auth.interceptor';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** In-app notification returned by keel's `InboxHandler`. */
|
|
2
|
+
export interface InboxMessage {
|
|
3
|
+
/** Server-side `BIGINT`, serialized as a string to preserve precision. */
|
|
4
|
+
id: string;
|
|
5
|
+
/** App-defined category (keel `notification_type`); '' when the sender set none. */
|
|
6
|
+
type: string;
|
|
7
|
+
title: string;
|
|
8
|
+
body: string;
|
|
9
|
+
/** Sender payload the app routes on (`{}` when the sender supplied none). */
|
|
10
|
+
data: Record<string, string>;
|
|
11
|
+
/** ISO timestamp, or null while unread. */
|
|
12
|
+
readAt: string | null;
|
|
13
|
+
createdAt: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** One page of the caller's inbox, newest first, plus the whole-inbox unread total. */
|
|
17
|
+
export interface InboxPage {
|
|
18
|
+
messages: InboxMessage[];
|
|
19
|
+
unreadCount: number;
|
|
20
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { InjectionToken } from '@angular/core';
|
|
2
|
+
import { Observable } from 'rxjs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Port for proving ownership of a domain: issue a code, then confirm it.
|
|
6
|
+
*
|
|
7
|
+
* sail ships no implementation — the endpoints, their paths and their payloads are
|
|
8
|
+
* the app's. Register one at composition time:
|
|
9
|
+
*
|
|
10
|
+
* { provide: DOMAIN_VERIFIER, useExisting: RegistrationService }
|
|
11
|
+
*
|
|
12
|
+
* Both methods may emit any body; `DomainVerificationComponent` reads only success
|
|
13
|
+
* or failure, and reports the failure through `errorDetail()`.
|
|
14
|
+
*/
|
|
15
|
+
export interface DomainVerifier {
|
|
16
|
+
requestVerification(domain: string): Observable<unknown>;
|
|
17
|
+
confirm(domain: string, code: string): Observable<unknown>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const DOMAIN_VERIFIER = new InjectionToken<DomainVerifier>('SailDomainVerifier');
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Injectable, Signal, signal } from '@angular/core';
|
|
2
|
+
import { HttpParams } from '@angular/common/http';
|
|
3
|
+
import { Observable, tap } from 'rxjs';
|
|
4
|
+
import { BaseRestService } from './base_rest.service';
|
|
5
|
+
import { RestURL } from './rest_url';
|
|
6
|
+
import { InboxMessage, InboxPage } from '../model/notification';
|
|
7
|
+
|
|
8
|
+
export const INBOX_MAX_PAGE_SIZE = 100;
|
|
9
|
+
export const INBOX_DEFAULT_PAGE_SIZE = 20;
|
|
10
|
+
|
|
11
|
+
/** Stateful client for keel's `InboxHandler` (v1.2.71+). Call `reset()` on logout. */
|
|
12
|
+
@Injectable({ providedIn: 'root' })
|
|
13
|
+
export class NotificationInboxService extends BaseRestService {
|
|
14
|
+
private readonly cached = signal<InboxMessage[]>([]);
|
|
15
|
+
private readonly unread = signal(0);
|
|
16
|
+
private readonly older = signal(false);
|
|
17
|
+
|
|
18
|
+
readonly messages: Signal<InboxMessage[]> = this.cached.asReadonly();
|
|
19
|
+
readonly unreadCount: Signal<number> = this.unread.asReadonly();
|
|
20
|
+
readonly hasMore: Signal<boolean> = this.older.asReadonly();
|
|
21
|
+
|
|
22
|
+
load(limit = INBOX_DEFAULT_PAGE_SIZE): Observable<InboxPage> {
|
|
23
|
+
return this.fetch('', limit).pipe(tap((page) => this.cached.set(page.messages)));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
loadMore(limit = INBOX_DEFAULT_PAGE_SIZE): Observable<InboxPage> {
|
|
27
|
+
const loaded = this.cached();
|
|
28
|
+
const oldest = loaded.length ? loaded[loaded.length - 1].id : '';
|
|
29
|
+
return this.fetch(oldest, limit).pipe(
|
|
30
|
+
tap((page) => this.cached.update((rows) => {
|
|
31
|
+
const ids = new Set(rows.map((message) => message.id));
|
|
32
|
+
return rows.concat(page.messages.filter((message) => !ids.has(message.id)));
|
|
33
|
+
})),
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
markRead(id: string): Observable<void> {
|
|
38
|
+
return this.http.post<void>(this.url(RestURL.notificationsMarkReadURL), { id }).pipe(
|
|
39
|
+
tap(() => {
|
|
40
|
+
const wasUnread = this.cached().some((m) => m.id === id && !m.readAt);
|
|
41
|
+
this.cached.update((rows) => rows.map((m) => (m.id === id ? { ...m, readAt: m.readAt ?? nowIso() } : m)));
|
|
42
|
+
if (wasUnread) this.unread.update((n) => Math.max(0, n - 1));
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
markAllRead(): Observable<void> {
|
|
48
|
+
return this.http.post<void>(this.url(RestURL.notificationsMarkAllReadURL), {}).pipe(
|
|
49
|
+
tap(() => {
|
|
50
|
+
this.cached.update((rows) => rows.map((m) => (m.readAt ? m : { ...m, readAt: nowIso() })));
|
|
51
|
+
this.unread.set(0);
|
|
52
|
+
}),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
reset(): void {
|
|
57
|
+
this.cached.set([]);
|
|
58
|
+
this.unread.set(0);
|
|
59
|
+
this.older.set(false);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private fetch(before: string, limit: number): Observable<InboxPage> {
|
|
63
|
+
const requested = Number.isFinite(limit) ? Math.trunc(limit) : INBOX_DEFAULT_PAGE_SIZE;
|
|
64
|
+
const size = Math.min(Math.max(1, requested), INBOX_MAX_PAGE_SIZE);
|
|
65
|
+
let params = new HttpParams().set('limit', size);
|
|
66
|
+
if (before) params = params.set('before', before);
|
|
67
|
+
return this.http.get<InboxPage>(this.url(RestURL.notificationsURL), { params }).pipe(
|
|
68
|
+
tap((page) => {
|
|
69
|
+
this.unread.set(page.unreadCount);
|
|
70
|
+
this.older.set(page.messages.length >= size);
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function nowIso(): string {
|
|
77
|
+
return new Date().toISOString();
|
|
78
|
+
}
|
package/src/service/rest_url.ts
CHANGED
|
@@ -46,6 +46,10 @@ export const RestURL = {
|
|
|
46
46
|
profileEmailConfirmURL: '/api/user/profile/email/confirm',
|
|
47
47
|
profilePhoneURL: '/api/user/profile/phone',
|
|
48
48
|
profilePhoneConfirmURL: '/api/user/profile/phone/confirm',
|
|
49
|
+
// Notification inbox (keel InboxHandler, v1.2.71+).
|
|
50
|
+
notificationsURL: '/api/notifications',
|
|
51
|
+
notificationsMarkReadURL: '/api/notifications/mark_read',
|
|
52
|
+
notificationsMarkAllReadURL: '/api/notifications/mark_all_read',
|
|
49
53
|
pushRegisterURL: '/api/push/register',
|
|
50
54
|
pushRevokeURL: '/api/push/revoke',
|
|
51
55
|
// Payout — keel/payout endpoints. keel mounts PayoutHandler.Routes(prefix)
|