@ttt-productions/chat-react 0.12.9 → 0.12.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/realtime/channel-client.d.ts +41 -3
- package/dist/realtime/channel-client.d.ts.map +1 -1
- package/dist/realtime/channel-client.js +218 -30
- package/dist/realtime/channel-client.js.map +1 -1
- package/dist/realtime/shared.d.ts +18 -0
- package/dist/realtime/shared.d.ts.map +1 -1
- package/dist/realtime/shared.js +18 -0
- package/dist/realtime/shared.js.map +1 -1
- package/dist/realtime/wire.d.ts +6 -3
- package/dist/realtime/wire.d.ts.map +1 -1
- package/dist/realtime/wire.js +3 -1
- package/dist/realtime/wire.js.map +1 -1
- package/dist/ui/ChatShell.d.ts.map +1 -1
- package/dist/ui/ChatShell.js +12 -1
- package/dist/ui/ChatShell.js.map +1 -1
- package/dist/ui/Composer.d.ts +15 -1
- package/dist/ui/Composer.d.ts.map +1 -1
- package/dist/ui/Composer.js +13 -3
- package/dist/ui/Composer.js.map +1 -1
- package/dist/ui/MessageItemDefault.d.ts +5 -0
- package/dist/ui/MessageItemDefault.d.ts.map +1 -1
- package/dist/ui/MessageItemDefault.js +57 -10
- package/dist/ui/MessageItemDefault.js.map +1 -1
- package/dist/ui/MessageList.d.ts +3 -0
- package/dist/ui/MessageList.d.ts.map +1 -1
- package/dist/ui/MessageList.js +2 -2
- package/dist/ui/MessageList.js.map +1 -1
- package/package.json +5 -5
- package/src/styles/chat.css +69 -5
package/dist/index.d.ts
CHANGED
|
@@ -13,7 +13,7 @@ export type { RealtimeChatClient, RealtimeTransportConfig, ChannelClientState, C
|
|
|
13
13
|
export type { ChatShellProps } from "./ui/ChatShell.js";
|
|
14
14
|
export { ChatShell } from "./ui/ChatShell.js";
|
|
15
15
|
export { MessageList } from "./ui/MessageList.js";
|
|
16
|
-
export type { ComposerProps } from "./ui/Composer.js";
|
|
16
|
+
export type { ComposerProps, ComposerHandle } from "./ui/Composer.js";
|
|
17
17
|
export { Composer } from "./ui/Composer.js";
|
|
18
18
|
export type { MessageItemDefaultProps } from "./ui/MessageItemDefault.js";
|
|
19
19
|
export { MessageItemDefault } from "./ui/MessageItemDefault.js";
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AASA,YAAY,EACV,cAAc,EACd,iBAAiB,EACjB,2BAA2B,EAC3B,oBAAoB,EACpB,iBAAiB,EACjB,iBAAiB,EACjB,eAAe,EACf,uBAAuB,EACvB,yBAAyB,EACzB,qBAAqB,GACtB,MAAM,YAAY,CAAC;AAGpB,YAAY,EACV,yBAAyB,EACzB,8BAA8B,GAC/B,MAAM,uCAAuC,CAAC;AAC/C,OAAO,EACL,yBAAyB,EACzB,4BAA4B,GAC7B,MAAM,uCAAuC,CAAC;AAM/C,YAAY,EACV,4BAA4B,EAC5B,gCAAgC,GACjC,MAAM,yCAAyC,CAAC;AACjD,OAAO,EACL,2BAA2B,EAC3B,+BAA+B,GAChC,MAAM,yCAAyC,CAAC;AAGjD,YAAY,EAAE,6BAA6B,EAAE,MAAM,sCAAsC,CAAC;AAC1F,OAAO,EACL,wBAAwB,EACxB,mBAAmB,EACnB,2BAA2B,EAC3B,qBAAqB,GACtB,MAAM,sCAAsC,CAAC;AAE9C,YAAY,EAAE,qBAAqB,EAAE,MAAM,4BAA4B,CAAC;AACxE,OAAO,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAC7D,OAAO,EAAE,eAAe,EAAE,MAAM,gCAAgC,CAAC;AAKjE,OAAO,EACL,wBAAwB,EACxB,iBAAiB,EACjB,aAAa,EACb,WAAW,EACX,uBAAuB,EACvB,oBAAoB,EACpB,qBAAqB,EACrB,uBAAuB,EACvB,gBAAgB,EAChB,gBAAgB,EAChB,YAAY,EACZ,YAAY,EACZ,UAAU,EACV,UAAU,GACX,MAAM,qBAAqB,CAAC;AAC7B,YAAY,EACV,kBAAkB,EAClB,uBAAuB,EACvB,kBAAkB,EAClB,mBAAmB,EACnB,gBAAgB,EAChB,iBAAiB,EACjB,6BAA6B,EAC7B,aAAa,EACb,eAAe,EACf,cAAc,EACd,cAAc,EACd,aAAa,EACb,cAAc,EACd,eAAe,EACf,cAAc,EACd,iBAAiB,EACjB,iBAAiB,EACjB,WAAW,GACZ,MAAM,qBAAqB,CAAC;AAU7B,YAAY,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAC9C,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAClD,YAAY,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AASA,YAAY,EACV,cAAc,EACd,iBAAiB,EACjB,2BAA2B,EAC3B,oBAAoB,EACpB,iBAAiB,EACjB,iBAAiB,EACjB,eAAe,EACf,uBAAuB,EACvB,yBAAyB,EACzB,qBAAqB,GACtB,MAAM,YAAY,CAAC;AAGpB,YAAY,EACV,yBAAyB,EACzB,8BAA8B,GAC/B,MAAM,uCAAuC,CAAC;AAC/C,OAAO,EACL,yBAAyB,EACzB,4BAA4B,GAC7B,MAAM,uCAAuC,CAAC;AAM/C,YAAY,EACV,4BAA4B,EAC5B,gCAAgC,GACjC,MAAM,yCAAyC,CAAC;AACjD,OAAO,EACL,2BAA2B,EAC3B,+BAA+B,GAChC,MAAM,yCAAyC,CAAC;AAGjD,YAAY,EAAE,6BAA6B,EAAE,MAAM,sCAAsC,CAAC;AAC1F,OAAO,EACL,wBAAwB,EACxB,mBAAmB,EACnB,2BAA2B,EAC3B,qBAAqB,GACtB,MAAM,sCAAsC,CAAC;AAE9C,YAAY,EAAE,qBAAqB,EAAE,MAAM,4BAA4B,CAAC;AACxE,OAAO,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAC7D,OAAO,EAAE,eAAe,EAAE,MAAM,gCAAgC,CAAC;AAKjE,OAAO,EACL,wBAAwB,EACxB,iBAAiB,EACjB,aAAa,EACb,WAAW,EACX,uBAAuB,EACvB,oBAAoB,EACpB,qBAAqB,EACrB,uBAAuB,EACvB,gBAAgB,EAChB,gBAAgB,EAChB,YAAY,EACZ,YAAY,EACZ,UAAU,EACV,UAAU,GACX,MAAM,qBAAqB,CAAC;AAC7B,YAAY,EACV,kBAAkB,EAClB,uBAAuB,EACvB,kBAAkB,EAClB,mBAAmB,EACnB,gBAAgB,EAChB,iBAAiB,EACjB,6BAA6B,EAC7B,aAAa,EACb,eAAe,EACf,cAAc,EACd,cAAc,EACd,aAAa,EACb,cAAc,EACd,eAAe,EACf,cAAc,EACd,iBAAiB,EACjB,iBAAiB,EACjB,WAAW,GACZ,MAAM,qBAAqB,CAAC;AAU7B,YAAY,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAC9C,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAClD,YAAY,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AACtE,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAC5C,YAAY,EAAE,uBAAuB,EAAE,MAAM,4BAA4B,CAAC;AAC1E,OAAO,EAAE,kBAAkB,EAAE,MAAM,4BAA4B,CAAC;AAChE,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9D,OAAO,EAAE,WAAW,EAAE,KAAK,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AAC/E,OAAO,EACL,mBAAmB,EACnB,KAAK,wBAAwB,GAC9B,MAAM,mCAAmC,CAAC;AAC3C,OAAO,EACL,sBAAsB,EACtB,KAAK,0BAA0B,EAC/B,KAAK,4BAA4B,EACjC,KAAK,iBAAiB,EACtB,KAAK,uBAAuB,GAC7B,MAAM,wCAAwC,CAAC"}
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AA2Bb,OAAO,EACL,yBAAyB,EACzB,4BAA4B,GAC7B,MAAM,uCAAuC,CAAC;AAU/C,OAAO,EACL,2BAA2B,EAC3B,+BAA+B,GAChC,MAAM,yCAAyC,CAAC;AAIjD,OAAO,EACL,wBAAwB,EACxB,mBAAmB,EACnB,2BAA2B,EAC3B,qBAAqB,GACtB,MAAM,sCAAsC,CAAC;AAG9C,OAAO,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAC7D,OAAO,EAAE,eAAe,EAAE,MAAM,gCAAgC,CAAC;AAEjE,+EAA+E;AAC/E,kFAAkF;AAClF,oFAAoF;AACpF,OAAO,EACL,wBAAwB,EACxB,iBAAiB,EACjB,aAAa,EACb,WAAW,EACX,uBAAuB,EACvB,oBAAoB,EACpB,qBAAqB,EACrB,uBAAuB,EACvB,gBAAgB,EAChB,gBAAgB,EAChB,YAAY,EACZ,YAAY,EACZ,UAAU,EACV,UAAU,GACX,MAAM,qBAAqB,CAAC;AA+B7B,OAAO,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAC9C,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAElD,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAE5C,OAAO,EAAE,kBAAkB,EAAE,MAAM,4BAA4B,CAAC;AAChE,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9D,OAAO,EAAE,WAAW,EAAyB,MAAM,2BAA2B,CAAC;AAC/E,OAAO,EACL,mBAAmB,GAEpB,MAAM,mCAAmC,CAAC;AAC3C,OAAO,EACL,sBAAsB,GAKvB,MAAM,wCAAwC,CAAC","sourcesContent":["\"use client\";\n\n// @ttt-productions/chat-react — chat React UI, hooks, the Firebase-client\n// adapter config types, and React render types. Depends on the pure\n// @ttt-productions/chat-core for contracts, parser, and grouping.\n//\n// Apps import chat CSS via \"@ttt-productions/chat-react/styles\".\n\n// React-coupled config + render types (previously on chat-core root).\nexport type {\n ChatCoreConfig,\n ChatTransportMode,\n ChatRealtimeTransportConfig,\n ChatAttachmentConfig,\n ChatUploadAdapter,\n ChatMentionConfig,\n MessageRenderer,\n MessageRendererRegistry,\n RenderableMentionProvider,\n MentionResultRenderer,\n} from \"./types.js\";\n\n// Attachment-URL resolver context (render-time URL building from mediaAssetId).\nexport type {\n ChatAttachmentUrlResolver,\n ChatAttachmentUrlProviderProps,\n} from \"./context/ChatAttachmentUrlContext.js\";\nexport {\n ChatAttachmentUrlProvider,\n useChatAttachmentUrlResolver,\n} from \"./context/ChatAttachmentUrlContext.js\";\n\n// Attachment media-component injection (optional) — the app hands chat its own\n// display-path wrapper (recovery adapter, diagnostics, telemetry) so chat\n// media rides the same pipeline as every other surface; default stays the\n// package MediaViewer.\nexport type {\n ChatAttachmentMediaComponent,\n ChatAttachmentMediaProviderProps,\n} from \"./context/ChatAttachmentMediaContext.js\";\nexport {\n ChatAttachmentMediaProvider,\n useChatAttachmentMediaComponent,\n} from \"./context/ChatAttachmentMediaContext.js\";\n\n// Name-resolver context.\nexport type { ChatNameResolverProviderProps } from \"./context/ChatNameResolverContext.js\";\nexport {\n ChatNameResolverProvider,\n useChatNameResolver,\n useOptionalChatNameResolver,\n useResolvedSenderName,\n} from \"./context/ChatNameResolverContext.js\";\n\nexport type { UseChatMessagesResult } from \"./hooks/useChatMessages.js\";\nexport { useChatMessages } from \"./hooks/useChatMessages.js\";\nexport { canAccessThread } from \"./hooks/useChatThreadAccess.js\";\n\n// Realtime (Cloudflare Durable Object) transport — the client half of the chat\n// worker wire protocol. Opt-in via `ChatCoreConfig.transport === 'realtime'`; the\n// firestore transport stays the unchanged default. See docs/packages/chat-react.md.\nexport {\n createRealtimeChatClient,\n createInboxClient,\n ChannelClient,\n InboxClient,\n useRealtimeChatMessages,\n browserSocketFactory,\n ChatAccessDeniedError,\n isChatAccessDeniedError,\n CHAT_SUBPROTOCOL,\n CHAT_CLOSE_CODES,\n CLIENT_FRAME,\n SERVER_FRAME,\n buildFrame,\n parseFrame,\n} from \"./realtime/index.js\";\nexport type {\n RealtimeChatClient,\n RealtimeTransportConfig,\n ChannelClientState,\n ChannelClientConfig,\n InboxClientState,\n InboxClientConfig,\n UseRealtimeChatMessagesResult,\n GrantProvider,\n TransportTimers,\n RealtimeStatus,\n RealtimeSocket,\n SocketFactory,\n SocketHandlers,\n ChannelRefTuple,\n WireMessageRow,\n WireInboxSnapshot,\n WireRegistryEntry,\n ServerFrame,\n} from \"./realtime/index.js\";\n\n// TODO (re-export when a consumer needs them): the moderation-overlay helpers\n// `RevisionKind`, `ModerationOverlay`, `applyModerationOverlay`, `overlayFromRow`,\n// and `MODERATION_REDACTED_TEXT` live in `./realtime` but are intentionally NOT\n// re-exported from this package root yet — the consuming app's renderer reads moderation\n// state off `message.meta` (the realtime client applies the overlay internally), so\n// nothing imports them from here today. Add them to the block above when a consumer\n// genuinely needs to apply the overlay directly. (Comment-only; rides the next publish.)\n\nexport type { ChatShellProps } from \"./ui/ChatShell.js\";\nexport { ChatShell } from \"./ui/ChatShell.js\";\nexport { MessageList } from \"./ui/MessageList.js\";\nexport type { ComposerProps } from \"./ui/Composer.js\";\nexport { Composer } from \"./ui/Composer.js\";\nexport type { MessageItemDefaultProps } from \"./ui/MessageItemDefault.js\";\nexport { MessageItemDefault } from \"./ui/MessageItemDefault.js\";\nexport { MessageActions, ThreadActions } from \"./ui/menus.js\";\nexport { MessageText, type MessageTextProps } from \"./mentions/MessageText.js\";\nexport {\n MentionAutocomplete,\n type MentionAutocompleteProps,\n} from \"./mentions/MentionAutocomplete.js\";\nexport {\n useMentionAutocomplete,\n type UseMentionAutocompleteArgs,\n type UseMentionAutocompleteResult,\n type AutocompleteState,\n type AutocompleteResultGroup,\n} from \"./mentions/use-mention-autocomplete.js\";\n"]}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AA2Bb,OAAO,EACL,yBAAyB,EACzB,4BAA4B,GAC7B,MAAM,uCAAuC,CAAC;AAU/C,OAAO,EACL,2BAA2B,EAC3B,+BAA+B,GAChC,MAAM,yCAAyC,CAAC;AAIjD,OAAO,EACL,wBAAwB,EACxB,mBAAmB,EACnB,2BAA2B,EAC3B,qBAAqB,GACtB,MAAM,sCAAsC,CAAC;AAG9C,OAAO,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAC7D,OAAO,EAAE,eAAe,EAAE,MAAM,gCAAgC,CAAC;AAEjE,+EAA+E;AAC/E,kFAAkF;AAClF,oFAAoF;AACpF,OAAO,EACL,wBAAwB,EACxB,iBAAiB,EACjB,aAAa,EACb,WAAW,EACX,uBAAuB,EACvB,oBAAoB,EACpB,qBAAqB,EACrB,uBAAuB,EACvB,gBAAgB,EAChB,gBAAgB,EAChB,YAAY,EACZ,YAAY,EACZ,UAAU,EACV,UAAU,GACX,MAAM,qBAAqB,CAAC;AA+B7B,OAAO,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAC9C,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAElD,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAE5C,OAAO,EAAE,kBAAkB,EAAE,MAAM,4BAA4B,CAAC;AAChE,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9D,OAAO,EAAE,WAAW,EAAyB,MAAM,2BAA2B,CAAC;AAC/E,OAAO,EACL,mBAAmB,GAEpB,MAAM,mCAAmC,CAAC;AAC3C,OAAO,EACL,sBAAsB,GAKvB,MAAM,wCAAwC,CAAC","sourcesContent":["\"use client\";\n\n// @ttt-productions/chat-react — chat React UI, hooks, the Firebase-client\n// adapter config types, and React render types. Depends on the pure\n// @ttt-productions/chat-core for contracts, parser, and grouping.\n//\n// Apps import chat CSS via \"@ttt-productions/chat-react/styles\".\n\n// React-coupled config + render types (previously on chat-core root).\nexport type {\n ChatCoreConfig,\n ChatTransportMode,\n ChatRealtimeTransportConfig,\n ChatAttachmentConfig,\n ChatUploadAdapter,\n ChatMentionConfig,\n MessageRenderer,\n MessageRendererRegistry,\n RenderableMentionProvider,\n MentionResultRenderer,\n} from \"./types.js\";\n\n// Attachment-URL resolver context (render-time URL building from mediaAssetId).\nexport type {\n ChatAttachmentUrlResolver,\n ChatAttachmentUrlProviderProps,\n} from \"./context/ChatAttachmentUrlContext.js\";\nexport {\n ChatAttachmentUrlProvider,\n useChatAttachmentUrlResolver,\n} from \"./context/ChatAttachmentUrlContext.js\";\n\n// Attachment media-component injection (optional) — the app hands chat its own\n// display-path wrapper (recovery adapter, diagnostics, telemetry) so chat\n// media rides the same pipeline as every other surface; default stays the\n// package MediaViewer.\nexport type {\n ChatAttachmentMediaComponent,\n ChatAttachmentMediaProviderProps,\n} from \"./context/ChatAttachmentMediaContext.js\";\nexport {\n ChatAttachmentMediaProvider,\n useChatAttachmentMediaComponent,\n} from \"./context/ChatAttachmentMediaContext.js\";\n\n// Name-resolver context.\nexport type { ChatNameResolverProviderProps } from \"./context/ChatNameResolverContext.js\";\nexport {\n ChatNameResolverProvider,\n useChatNameResolver,\n useOptionalChatNameResolver,\n useResolvedSenderName,\n} from \"./context/ChatNameResolverContext.js\";\n\nexport type { UseChatMessagesResult } from \"./hooks/useChatMessages.js\";\nexport { useChatMessages } from \"./hooks/useChatMessages.js\";\nexport { canAccessThread } from \"./hooks/useChatThreadAccess.js\";\n\n// Realtime (Cloudflare Durable Object) transport — the client half of the chat\n// worker wire protocol. Opt-in via `ChatCoreConfig.transport === 'realtime'`; the\n// firestore transport stays the unchanged default. See docs/packages/chat-react.md.\nexport {\n createRealtimeChatClient,\n createInboxClient,\n ChannelClient,\n InboxClient,\n useRealtimeChatMessages,\n browserSocketFactory,\n ChatAccessDeniedError,\n isChatAccessDeniedError,\n CHAT_SUBPROTOCOL,\n CHAT_CLOSE_CODES,\n CLIENT_FRAME,\n SERVER_FRAME,\n buildFrame,\n parseFrame,\n} from \"./realtime/index.js\";\nexport type {\n RealtimeChatClient,\n RealtimeTransportConfig,\n ChannelClientState,\n ChannelClientConfig,\n InboxClientState,\n InboxClientConfig,\n UseRealtimeChatMessagesResult,\n GrantProvider,\n TransportTimers,\n RealtimeStatus,\n RealtimeSocket,\n SocketFactory,\n SocketHandlers,\n ChannelRefTuple,\n WireMessageRow,\n WireInboxSnapshot,\n WireRegistryEntry,\n ServerFrame,\n} from \"./realtime/index.js\";\n\n// TODO (re-export when a consumer needs them): the moderation-overlay helpers\n// `RevisionKind`, `ModerationOverlay`, `applyModerationOverlay`, `overlayFromRow`,\n// and `MODERATION_REDACTED_TEXT` live in `./realtime` but are intentionally NOT\n// re-exported from this package root yet — the consuming app's renderer reads moderation\n// state off `message.meta` (the realtime client applies the overlay internally), so\n// nothing imports them from here today. Add them to the block above when a consumer\n// genuinely needs to apply the overlay directly. (Comment-only; rides the next publish.)\n\nexport type { ChatShellProps } from \"./ui/ChatShell.js\";\nexport { ChatShell } from \"./ui/ChatShell.js\";\nexport { MessageList } from \"./ui/MessageList.js\";\nexport type { ComposerProps, ComposerHandle } from \"./ui/Composer.js\";\nexport { Composer } from \"./ui/Composer.js\";\nexport type { MessageItemDefaultProps } from \"./ui/MessageItemDefault.js\";\nexport { MessageItemDefault } from \"./ui/MessageItemDefault.js\";\nexport { MessageActions, ThreadActions } from \"./ui/menus.js\";\nexport { MessageText, type MessageTextProps } from \"./mentions/MessageText.js\";\nexport {\n MentionAutocomplete,\n type MentionAutocompleteProps,\n} from \"./mentions/MentionAutocomplete.js\";\nexport {\n useMentionAutocomplete,\n type UseMentionAutocompleteArgs,\n type UseMentionAutocompleteResult,\n type AutocompleteState,\n type AutocompleteResultGroup,\n} from \"./mentions/use-mention-autocomplete.js\";\n"]}
|
|
@@ -107,13 +107,51 @@ export declare class ChannelClient {
|
|
|
107
107
|
/**
|
|
108
108
|
* Flip an un-acked optimistic row to a visible failed state. The pending entry is
|
|
109
109
|
* RETAINED (marked `failed`) so retrySend() can re-queue it with the SAME
|
|
110
|
-
* clientMessageId; the row is marked `meta.sendFailed`
|
|
111
|
-
*
|
|
112
|
-
*
|
|
110
|
+
* clientMessageId; the row is marked `meta.sendFailed` (plus the correlated
|
|
111
|
+
* `sendFailureCode`/`sendRetryable` when a `send-rejected` frame supplied them) so
|
|
112
|
+
* the default renderer shows the right reason + retry affordance rather than an
|
|
113
|
+
* indistinguishable "sent" bubble. A late ack/echo still reconciles + clears it.
|
|
114
|
+
*
|
|
115
|
+
* `code`/`retryable` are present for a correlated rejection (terminal) or a
|
|
116
|
+
* budget-exhausted server-retryable send; absent for a blind reconnect give-up
|
|
117
|
+
* (which keeps the neutral `send-failed` code).
|
|
113
118
|
*/
|
|
114
119
|
private failPendingSend;
|
|
120
|
+
/** Clear a pending send's per-send retry timer (idempotent). */
|
|
121
|
+
private clearSendRetryTimer;
|
|
122
|
+
/**
|
|
123
|
+
* Stop tracking a send (acked / reconciled by an echo / history row): cancel its
|
|
124
|
+
* per-send retry timer first so a late reconcile never leaves a stray timer that
|
|
125
|
+
* would resend after the send already landed, then drop it from the map.
|
|
126
|
+
*/
|
|
127
|
+
private clearPendingSend;
|
|
115
128
|
/** One timer for the membership-pending re-send window; a newer frame resets it. */
|
|
116
129
|
private scheduleMembershipPendingRetry;
|
|
130
|
+
/**
|
|
131
|
+
* Handle a CORRELATED `send-rejected` frame (the target protocol): parse the
|
|
132
|
+
* untrusted payload with the shared schema, correlate to EXACTLY ONE pending send
|
|
133
|
+
* by clientMessageId, and either fail just that bubble (terminal) or schedule a
|
|
134
|
+
* single per-send resend (retryable).
|
|
135
|
+
*
|
|
136
|
+
* A frame that fails validation is NOT simply dropped when it still names one of
|
|
137
|
+
* OUR pending sends: unlike an unknown frame type, an id-correlated rejection is
|
|
138
|
+
* the server explicitly telling us this send did not land — swallowing it would
|
|
139
|
+
* strand the bubble on "Sending…" forever (no further frame, no reconnect, no
|
|
140
|
+
* budget check ever runs). A future/unknown rejection code (version skew during a
|
|
141
|
+
* rollout) therefore fails that send CLOSED with the neutral copy + Retry. A
|
|
142
|
+
* malformed frame with no recognizable pending id is dropped (forward-compat
|
|
143
|
+
* noise), never surfaced as an error.
|
|
144
|
+
*/
|
|
145
|
+
private applySendRejected;
|
|
146
|
+
/**
|
|
147
|
+
* Schedule EXACTLY ONE per-send resend of a correlated retryable rejection (never
|
|
148
|
+
* stacks — a newer rejection clears the prior timer first). When it fires it
|
|
149
|
+
* re-sends the SAME clientMessageId (the DO dedups) unless the wall-clock budget
|
|
150
|
+
* has elapsed, in which case the send fails with its recorded reason.
|
|
151
|
+
*/
|
|
152
|
+
private scheduleSendRetry;
|
|
153
|
+
/** Clamp a server `retryAfterMs` hint into a sane resend delay (defaulting when absent). */
|
|
154
|
+
private clampRetryDelay;
|
|
117
155
|
/**
|
|
118
156
|
* Flip EVERY un-acked pending send to the visible failed state. Called on the
|
|
119
157
|
* terminal close paths (4403 REVOKED, reconnect give-up) — without this, a send
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"channel-client.d.ts","sourceRoot":"","sources":["../../src/realtime/channel-client.ts"],"names":[],"mappings":"AAkBA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;
|
|
1
|
+
{"version":3,"file":"channel-client.d.ts","sourceRoot":"","sources":["../../src/realtime/channel-client.ts"],"names":[],"mappings":"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"}
|
|
@@ -15,9 +15,9 @@
|
|
|
15
15
|
//
|
|
16
16
|
// Timers are injected (setTimeout/clearTimeout/now) so tests run deterministically.
|
|
17
17
|
import { createReconnectController } from '@ttt-productions/realtime-core';
|
|
18
|
-
import { CLIENT_FRAME, CHAT_CLOSE_CODES, buildFrame, parseFrame, isInboxSnapshot, } from './wire.js';
|
|
18
|
+
import { CLIENT_FRAME, CHAT_CLOSE_CODES, buildFrame, parseFrame, isInboxSnapshot, ChatSendRejectedPayloadSchema, } from './wire.js';
|
|
19
19
|
import { wireRowToMessage, optimisticMessage, seqToMessageId, applyModerationOverlay, overlayFromRow, } from './map.js';
|
|
20
|
-
import { defaultTimers, isChatAccessDeniedError, HEARTBEAT_MS, TYPING_COALESCE_MS, HISTORY_PAGE_MAX, MAX_PENDING_SEND_ATTEMPTS, PENDING_SEND_MAX_AGE_MS, } from './shared.js';
|
|
20
|
+
import { defaultTimers, isChatAccessDeniedError, HEARTBEAT_MS, TYPING_COALESCE_MS, HISTORY_PAGE_MAX, MAX_PENDING_SEND_ATTEMPTS, PENDING_SEND_MAX_AGE_MS, SERVER_RETRYABLE_MAX_AGE_MS, SEND_RETRY_MIN_DELAY_MS, SEND_RETRY_MAX_DELAY_MS, } from './shared.js';
|
|
21
21
|
const INITIAL = {
|
|
22
22
|
status: 'idle',
|
|
23
23
|
messages: [],
|
|
@@ -182,11 +182,29 @@ export class ChannelClient {
|
|
|
182
182
|
// re-queues it; the automatic reconnect resend leaves it alone.
|
|
183
183
|
if (pending.failed)
|
|
184
184
|
continue;
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
185
|
+
if (pending.serverRetryable) {
|
|
186
|
+
// The Worker declared this send retryable — give-up is the wall-clock budget
|
|
187
|
+
// ONLY (the 5-attempt cap must not fail a server-declared transient after
|
|
188
|
+
// ~10 s while the backend projection window is up to 60+ s).
|
|
189
|
+
if (now - pending.sentAt >= SERVER_RETRYABLE_MAX_AGE_MS) {
|
|
190
|
+
this.failPendingSend(pending.clientMessageId, pending.lastRejectionCode, true);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
// This reconnect resend supersedes any per-send timer scheduled before the
|
|
194
|
+
// socket dropped — clear it so the send doesn't go out twice (the DO would
|
|
195
|
+
// dedup, but the duplicate frame is pure waste). A fresh rejection frame
|
|
196
|
+
// re-schedules as usual.
|
|
197
|
+
this.clearSendRetryTimer(pending);
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
// A blind reconnect resend of a send the server has said nothing about: the
|
|
201
|
+
// generic attempt/age caps still protect against a permanent ghost.
|
|
202
|
+
const tooOld = now - pending.sentAt >= PENDING_SEND_MAX_AGE_MS;
|
|
203
|
+
const tooManyAttempts = pending.attempts >= MAX_PENDING_SEND_ATTEMPTS;
|
|
204
|
+
if (tooOld || tooManyAttempts) {
|
|
205
|
+
this.failPendingSend(pending.clientMessageId);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
190
208
|
}
|
|
191
209
|
const sent = this.sendFrame(CLIENT_FRAME.SEND, {
|
|
192
210
|
clientMessageId: pending.clientMessageId,
|
|
@@ -200,19 +218,52 @@ export class ChannelClient {
|
|
|
200
218
|
/**
|
|
201
219
|
* Flip an un-acked optimistic row to a visible failed state. The pending entry is
|
|
202
220
|
* RETAINED (marked `failed`) so retrySend() can re-queue it with the SAME
|
|
203
|
-
* clientMessageId; the row is marked `meta.sendFailed`
|
|
204
|
-
*
|
|
205
|
-
*
|
|
221
|
+
* clientMessageId; the row is marked `meta.sendFailed` (plus the correlated
|
|
222
|
+
* `sendFailureCode`/`sendRetryable` when a `send-rejected` frame supplied them) so
|
|
223
|
+
* the default renderer shows the right reason + retry affordance rather than an
|
|
224
|
+
* indistinguishable "sent" bubble. A late ack/echo still reconciles + clears it.
|
|
225
|
+
*
|
|
226
|
+
* `code`/`retryable` are present for a correlated rejection (terminal) or a
|
|
227
|
+
* budget-exhausted server-retryable send; absent for a blind reconnect give-up
|
|
228
|
+
* (which keeps the neutral `send-failed` code).
|
|
206
229
|
*/
|
|
207
|
-
failPendingSend(clientMessageId) {
|
|
230
|
+
failPendingSend(clientMessageId, code, retryable) {
|
|
208
231
|
const pending = this.pendingSends.get(clientMessageId);
|
|
209
232
|
if (!pending || pending.failed)
|
|
210
233
|
return;
|
|
211
234
|
pending.failed = true;
|
|
235
|
+
// A terminal/exhausted send never retries on a timer — cancel any scheduled one.
|
|
236
|
+
this.clearSendRetryTimer(pending);
|
|
212
237
|
const next = this.state.messages.map((m) => m.meta?.optimistic && m.meta?.clientMessageId === clientMessageId
|
|
213
|
-
? {
|
|
238
|
+
? {
|
|
239
|
+
...m,
|
|
240
|
+
meta: {
|
|
241
|
+
...m.meta,
|
|
242
|
+
sendFailed: true,
|
|
243
|
+
...(code ? { sendFailureCode: code } : {}),
|
|
244
|
+
...(retryable != null ? { sendRetryable: retryable } : {}),
|
|
245
|
+
},
|
|
246
|
+
}
|
|
214
247
|
: m);
|
|
215
|
-
this.setState({ messages: next, lastErrorCode: 'send-failed' });
|
|
248
|
+
this.setState({ messages: next, lastErrorCode: code ?? 'send-failed' });
|
|
249
|
+
}
|
|
250
|
+
/** Clear a pending send's per-send retry timer (idempotent). */
|
|
251
|
+
clearSendRetryTimer(pending) {
|
|
252
|
+
if (pending.retryTimer != null) {
|
|
253
|
+
this.timers.clearTimeout(pending.retryTimer);
|
|
254
|
+
pending.retryTimer = null;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Stop tracking a send (acked / reconciled by an echo / history row): cancel its
|
|
259
|
+
* per-send retry timer first so a late reconcile never leaves a stray timer that
|
|
260
|
+
* would resend after the send already landed, then drop it from the map.
|
|
261
|
+
*/
|
|
262
|
+
clearPendingSend(clientMessageId) {
|
|
263
|
+
const pending = this.pendingSends.get(clientMessageId);
|
|
264
|
+
if (pending)
|
|
265
|
+
this.clearSendRetryTimer(pending);
|
|
266
|
+
this.pendingSends.delete(clientMessageId);
|
|
216
267
|
}
|
|
217
268
|
/** One timer for the membership-pending re-send window; a newer frame resets it. */
|
|
218
269
|
scheduleMembershipPendingRetry(delayMs) {
|
|
@@ -225,6 +276,116 @@ export class ChannelClient {
|
|
|
225
276
|
this.resendPendingSends();
|
|
226
277
|
}, delayMs);
|
|
227
278
|
}
|
|
279
|
+
/**
|
|
280
|
+
* Handle a CORRELATED `send-rejected` frame (the target protocol): parse the
|
|
281
|
+
* untrusted payload with the shared schema, correlate to EXACTLY ONE pending send
|
|
282
|
+
* by clientMessageId, and either fail just that bubble (terminal) or schedule a
|
|
283
|
+
* single per-send resend (retryable).
|
|
284
|
+
*
|
|
285
|
+
* A frame that fails validation is NOT simply dropped when it still names one of
|
|
286
|
+
* OUR pending sends: unlike an unknown frame type, an id-correlated rejection is
|
|
287
|
+
* the server explicitly telling us this send did not land — swallowing it would
|
|
288
|
+
* strand the bubble on "Sending…" forever (no further frame, no reconnect, no
|
|
289
|
+
* budget check ever runs). A future/unknown rejection code (version skew during a
|
|
290
|
+
* rollout) therefore fails that send CLOSED with the neutral copy + Retry. A
|
|
291
|
+
* malformed frame with no recognizable pending id is dropped (forward-compat
|
|
292
|
+
* noise), never surfaced as an error.
|
|
293
|
+
*/
|
|
294
|
+
applySendRejected(payload) {
|
|
295
|
+
const parsed = ChatSendRejectedPayloadSchema.safeParse(payload);
|
|
296
|
+
if (!parsed.success) {
|
|
297
|
+
// Defensive fail-closed: if the unparseable rejection still carries a string
|
|
298
|
+
// id matching one of our un-failed pending sends, that send was rejected by a
|
|
299
|
+
// server speaking a newer/buggy dialect — fail it visibly (neutral code-less
|
|
300
|
+
// "Couldn't send" + Retry) instead of stranding it.
|
|
301
|
+
const rawId = payload.clientMessageId;
|
|
302
|
+
if (typeof rawId === 'string' && rawId.length > 0 && rawId.length <= 200) {
|
|
303
|
+
const orphaned = this.pendingSends.get(rawId);
|
|
304
|
+
if (orphaned && !orphaned.failed)
|
|
305
|
+
this.failPendingSend(rawId);
|
|
306
|
+
}
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const { clientMessageId, code, retryable, retryAfterMs } = parsed.data;
|
|
310
|
+
const pending = this.pendingSends.get(clientMessageId);
|
|
311
|
+
// No matching pending send — already acked/reconciled, or never ours. A late
|
|
312
|
+
// ack/echo (which may have already landed) always wins; nothing to correlate.
|
|
313
|
+
if (!pending || pending.failed)
|
|
314
|
+
return;
|
|
315
|
+
if (!retryable) {
|
|
316
|
+
// Terminal: an unchanged resend cannot succeed (blocked-word / archived /
|
|
317
|
+
// deleted). Fail ONLY this bubble, recording the reason + terminal class.
|
|
318
|
+
this.failPendingSend(clientMessageId, code, false);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
// Retryable: server-declared, so switch this send onto the wall-clock budget
|
|
322
|
+
// (the 5-attempt cap no longer applies) and record the reason for a possible
|
|
323
|
+
// budget-exhausted failure.
|
|
324
|
+
pending.serverRetryable = true;
|
|
325
|
+
pending.lastRejectionCode = code;
|
|
326
|
+
// If the budget already elapsed (a long stream of retryable rejections), fail
|
|
327
|
+
// now rather than scheduling yet another doomed resend.
|
|
328
|
+
if (this.timers.now() - pending.sentAt >= SERVER_RETRYABLE_MAX_AGE_MS) {
|
|
329
|
+
this.failPendingSend(clientMessageId, code, true);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
this.scheduleSendRetry(clientMessageId, this.clampRetryDelay(retryAfterMs));
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Schedule EXACTLY ONE per-send resend of a correlated retryable rejection (never
|
|
336
|
+
* stacks — a newer rejection clears the prior timer first). When it fires it
|
|
337
|
+
* re-sends the SAME clientMessageId (the DO dedups) unless the wall-clock budget
|
|
338
|
+
* has elapsed, in which case the send fails with its recorded reason.
|
|
339
|
+
*/
|
|
340
|
+
scheduleSendRetry(clientMessageId, delayMs) {
|
|
341
|
+
const pending = this.pendingSends.get(clientMessageId);
|
|
342
|
+
if (!pending)
|
|
343
|
+
return;
|
|
344
|
+
this.clearSendRetryTimer(pending);
|
|
345
|
+
pending.retryTimer = this.timers.setTimeout(() => {
|
|
346
|
+
pending.retryTimer = null;
|
|
347
|
+
if (this.closedByUs)
|
|
348
|
+
return;
|
|
349
|
+
const current = this.pendingSends.get(clientMessageId);
|
|
350
|
+
if (!current || current.failed)
|
|
351
|
+
return;
|
|
352
|
+
if (this.timers.now() - current.sentAt >= SERVER_RETRYABLE_MAX_AGE_MS) {
|
|
353
|
+
this.failPendingSend(clientMessageId, current.lastRejectionCode, true);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
// A server-retryable resend does NOT count toward the attempt cap (that cap is
|
|
357
|
+
// for blind reconnect resends only) — leave `attempts` untouched.
|
|
358
|
+
this.sendFrame(CLIENT_FRAME.SEND, {
|
|
359
|
+
clientMessageId: current.clientMessageId,
|
|
360
|
+
text: current.text,
|
|
361
|
+
replyTo: current.replyTo,
|
|
362
|
+
});
|
|
363
|
+
// Budget backstop: the contract says every send gets an ack or a rejection,
|
|
364
|
+
// and either one replaces/clears this timer. If the server goes silent (a
|
|
365
|
+
// contract violation), NOTHING else would ever re-check the budget on a
|
|
366
|
+
// stable socket — so arm a deadline check that fails the send at budget end.
|
|
367
|
+
// This makes SERVER_RETRYABLE_MAX_AGE_MS a hard ceiling, not a best effort.
|
|
368
|
+
const remainingMs = SERVER_RETRYABLE_MAX_AGE_MS - (this.timers.now() - current.sentAt) + SEND_RETRY_MIN_DELAY_MS;
|
|
369
|
+
current.retryTimer = this.timers.setTimeout(() => {
|
|
370
|
+
current.retryTimer = null;
|
|
371
|
+
if (this.closedByUs)
|
|
372
|
+
return;
|
|
373
|
+
const atDeadline = this.pendingSends.get(clientMessageId);
|
|
374
|
+
if (!atDeadline || atDeadline.failed)
|
|
375
|
+
return;
|
|
376
|
+
if (this.timers.now() - atDeadline.sentAt >= SERVER_RETRYABLE_MAX_AGE_MS) {
|
|
377
|
+
this.failPendingSend(clientMessageId, atDeadline.lastRejectionCode, true);
|
|
378
|
+
}
|
|
379
|
+
}, remainingMs);
|
|
380
|
+
}, delayMs);
|
|
381
|
+
}
|
|
382
|
+
/** Clamp a server `retryAfterMs` hint into a sane resend delay (defaulting when absent). */
|
|
383
|
+
clampRetryDelay(retryAfterMs) {
|
|
384
|
+
const raw = typeof retryAfterMs === 'number' && retryAfterMs > 0
|
|
385
|
+
? retryAfterMs
|
|
386
|
+
: MEMBERSHIP_PENDING_DEFAULT_RETRY_MS;
|
|
387
|
+
return Math.min(Math.max(raw, SEND_RETRY_MIN_DELAY_MS), SEND_RETRY_MAX_DELAY_MS);
|
|
388
|
+
}
|
|
228
389
|
/**
|
|
229
390
|
* Flip EVERY un-acked pending send to the visible failed state. Called on the
|
|
230
391
|
* terminal close paths (4403 REVOKED, reconnect give-up) — without this, a send
|
|
@@ -248,7 +409,12 @@ export class ChannelClient {
|
|
|
248
409
|
const pending = this.pendingSends.get(clientMessageId);
|
|
249
410
|
if (!pending)
|
|
250
411
|
return false;
|
|
412
|
+
// A manual retry supersedes any scheduled server-retryable resend and starts a
|
|
413
|
+
// fresh budget from now; the server re-declares retryability if it rejects again.
|
|
414
|
+
this.clearSendRetryTimer(pending);
|
|
251
415
|
pending.failed = false;
|
|
416
|
+
pending.serverRetryable = false;
|
|
417
|
+
pending.lastRejectionCode = undefined;
|
|
252
418
|
pending.sentAt = this.timers.now();
|
|
253
419
|
const sent = this.sendFrame(CLIENT_FRAME.SEND, {
|
|
254
420
|
clientMessageId: pending.clientMessageId,
|
|
@@ -256,9 +422,14 @@ export class ChannelClient {
|
|
|
256
422
|
replyTo: pending.replyTo,
|
|
257
423
|
});
|
|
258
424
|
pending.attempts = sent ? 1 : 0;
|
|
259
|
-
const next = this.state.messages.map((m) =>
|
|
260
|
-
|
|
261
|
-
|
|
425
|
+
const next = this.state.messages.map((m) => {
|
|
426
|
+
if (!(m.meta?.optimistic && m.meta?.clientMessageId === clientMessageId && m.meta?.sendFailed)) {
|
|
427
|
+
return m;
|
|
428
|
+
}
|
|
429
|
+
// Back to the normal pending look — drop every failure marker.
|
|
430
|
+
const { sendFailed: _sendFailed, sendFailureCode: _sendFailureCode, sendRetryable: _sendRetryable, ...restMeta } = m.meta;
|
|
431
|
+
return { ...m, meta: { ...restMeta } };
|
|
432
|
+
});
|
|
262
433
|
this.setState({ messages: next, lastErrorCode: null });
|
|
263
434
|
return true;
|
|
264
435
|
}
|
|
@@ -343,16 +514,26 @@ export class ChannelClient {
|
|
|
343
514
|
return this.applyTyping(payload.uid);
|
|
344
515
|
case 'snapshot':
|
|
345
516
|
return this.applyChannelSnapshot(payload);
|
|
517
|
+
case 'send-rejected':
|
|
518
|
+
return this.applySendRejected(payload);
|
|
346
519
|
case 'error': {
|
|
347
520
|
const code = String(payload.code ?? 'error');
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
//
|
|
521
|
+
// ROLLOUT COMPATIBILITY ONLY (not the target protocol): a Worker that predates
|
|
522
|
+
// the correlated `send-rejected` frame emits an UNCORRELATED
|
|
523
|
+
// error{code:'membership-pending'} with no clientMessageId, so we cannot target
|
|
524
|
+
// one send. Keep the send(s) pending (no visible error — the bubble stays
|
|
525
|
+
// "Sending…"), mark every in-flight send server-retryable so the wall-clock
|
|
526
|
+
// budget (NOT the 5-attempt cap) governs give-up, and re-send all after the
|
|
527
|
+
// server-suggested delay. The correlated path below is the target protocol.
|
|
353
528
|
if (code === MEMBERSHIP_PENDING_CODE) {
|
|
354
529
|
const raw = payload.retryAfterMs;
|
|
355
|
-
const delay = typeof raw === 'number'
|
|
530
|
+
const delay = this.clampRetryDelay(typeof raw === 'number' ? raw : undefined);
|
|
531
|
+
for (const pending of this.pendingSends.values()) {
|
|
532
|
+
if (!pending.failed) {
|
|
533
|
+
pending.serverRetryable = true;
|
|
534
|
+
pending.lastRejectionCode = MEMBERSHIP_PENDING_CODE;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
356
537
|
return this.scheduleMembershipPendingRetry(delay);
|
|
357
538
|
}
|
|
358
539
|
return this.setState({ lastErrorCode: code });
|
|
@@ -482,9 +663,9 @@ export class ChannelClient {
|
|
|
482
663
|
mergeMessage(mapped, row) {
|
|
483
664
|
// If this canonical row carries a clientMessageId we still have pending (the
|
|
484
665
|
// sender's own broadcast, or a resume delta that included our send), the send
|
|
485
|
-
// landed — stop tracking it for reconnect resend.
|
|
666
|
+
// landed — stop tracking it for reconnect resend (and cancel any retry timer).
|
|
486
667
|
if (row.clientMessageId)
|
|
487
|
-
this.
|
|
668
|
+
this.clearPendingSend(row.clientMessageId);
|
|
488
669
|
const next = this.state.messages.filter((m) => m.messageId !== mapped.messageId &&
|
|
489
670
|
// drop the optimistic placeholder this server row reconciles
|
|
490
671
|
(m.meta?.clientMessageId !== row.clientMessageId || !m.meta?.optimistic));
|
|
@@ -501,14 +682,16 @@ export class ChannelClient {
|
|
|
501
682
|
// ack guarantees the seq even if the broadcast is dropped/reordered.
|
|
502
683
|
const seq = payload.seq;
|
|
503
684
|
const cmid = payload.clientMessageId;
|
|
504
|
-
// The send is acked — stop tracking it for reconnect resend
|
|
505
|
-
|
|
685
|
+
// The send is acked — stop tracking it for reconnect resend (and cancel any
|
|
686
|
+
// per-send retry timer so a scheduled resend never fires after it landed).
|
|
687
|
+
this.clearPendingSend(cmid);
|
|
506
688
|
const next = this.state.messages.map((m) => {
|
|
507
689
|
if (!m.meta?.optimistic || m.meta?.clientMessageId !== cmid)
|
|
508
690
|
return m;
|
|
509
|
-
// Strip any failed
|
|
510
|
-
//
|
|
511
|
-
|
|
691
|
+
// Strip any failed markers — a late ack (e.g. a retry raced the cap, or a
|
|
692
|
+
// correlated rejection was superseded) means the send actually landed, so the
|
|
693
|
+
// row must render as delivered, not failed.
|
|
694
|
+
const { sendFailed: _sendFailed, sendFailureCode: _sendFailureCode, sendRetryable: _sendRetryable, ...restMeta } = m.meta;
|
|
512
695
|
return { ...m, messageId: seqToMessageId(seq), meta: { ...restMeta, optimistic: false, seq } };
|
|
513
696
|
});
|
|
514
697
|
next.sort(sortBySeq);
|
|
@@ -527,7 +710,7 @@ export class ChannelClient {
|
|
|
527
710
|
for (const r of rows) {
|
|
528
711
|
if (r.clientMessageId) {
|
|
529
712
|
incomingCmids.add(r.clientMessageId);
|
|
530
|
-
this.
|
|
713
|
+
this.clearPendingSend(r.clientMessageId);
|
|
531
714
|
}
|
|
532
715
|
}
|
|
533
716
|
const bySeq = new Map();
|
|
@@ -601,6 +784,8 @@ export class ChannelClient {
|
|
|
601
784
|
sentAt: now,
|
|
602
785
|
attempts: 1,
|
|
603
786
|
failed: false,
|
|
787
|
+
serverRetryable: false,
|
|
788
|
+
retryTimer: null,
|
|
604
789
|
});
|
|
605
790
|
const echo = optimisticMessage({
|
|
606
791
|
clientMessageId: args.clientMessageId,
|
|
@@ -681,6 +866,9 @@ export class ChannelClient {
|
|
|
681
866
|
this.typingTimers.clear();
|
|
682
867
|
this.overlays.clear();
|
|
683
868
|
this.originalRows.clear();
|
|
869
|
+
// Cancel every per-send retry timer before dropping the tracking map.
|
|
870
|
+
for (const pending of this.pendingSends.values())
|
|
871
|
+
this.clearSendRetryTimer(pending);
|
|
684
872
|
this.pendingSends.clear();
|
|
685
873
|
if (this.reconnectTimer != null) {
|
|
686
874
|
this.timers.clearTimeout(this.reconnectTimer);
|