@linktr.ee/messaging-react 3.6.1 → 3.7.0-rc-1783304153

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.
Files changed (39) hide show
  1. package/dist/{Card-CxS7GNqp.js → Card-BAt0xifq.js} +2 -2
  2. package/dist/{Card-CxS7GNqp.js.map → Card-BAt0xifq.js.map} +1 -1
  3. package/dist/{Card-B0lzpuYf.js → Card-BB34_MDl.js} +3 -3
  4. package/dist/{Card-B0lzpuYf.js.map → Card-BB34_MDl.js.map} +1 -1
  5. package/dist/{Card-DcUPqd79.cjs → Card-BDKGSydN.cjs} +2 -2
  6. package/dist/{Card-DcUPqd79.cjs.map → Card-BDKGSydN.cjs.map} +1 -1
  7. package/dist/{Card-BuR6mbUu.cjs → Card-BGJY5HWf.cjs} +2 -2
  8. package/dist/{Card-BuR6mbUu.cjs.map → Card-BGJY5HWf.cjs.map} +1 -1
  9. package/dist/{Card-BrC7mKtr.js → Card-C3-Ea5ho.js} +2 -2
  10. package/dist/{Card-BrC7mKtr.js.map → Card-C3-Ea5ho.js.map} +1 -1
  11. package/dist/{Card-d0_xEfdM.cjs → Card-DnFtpG1h.cjs} +2 -2
  12. package/dist/{Card-d0_xEfdM.cjs.map → Card-DnFtpG1h.cjs.map} +1 -1
  13. package/dist/{LockedThumbnail-BDtKMJjz.cjs → LockedThumbnail-Bl9UC9sQ.cjs} +2 -2
  14. package/dist/{LockedThumbnail-BDtKMJjz.cjs.map → LockedThumbnail-Bl9UC9sQ.cjs.map} +1 -1
  15. package/dist/{LockedThumbnail-BaDgF8qJ.js → LockedThumbnail-so7ftBnG.js} +2 -2
  16. package/dist/{LockedThumbnail-BaDgF8qJ.js.map → LockedThumbnail-so7ftBnG.js.map} +1 -1
  17. package/dist/{index-BPfehHDD.js → index-2HMjfjM-.js} +1013 -1006
  18. package/dist/index-2HMjfjM-.js.map +1 -0
  19. package/dist/index-BgzqZorr.cjs +2 -0
  20. package/dist/index-BgzqZorr.cjs.map +1 -0
  21. package/dist/index.cjs +1 -1
  22. package/dist/index.d.ts +14 -2
  23. package/dist/index.js +1 -1
  24. package/dist/testing.cjs +2 -0
  25. package/dist/testing.cjs.map +1 -0
  26. package/dist/testing.d.ts +116 -0
  27. package/dist/testing.js +107 -0
  28. package/dist/testing.js.map +1 -0
  29. package/package.json +7 -2
  30. package/src/providers/MessagingProvider.test.tsx +30 -0
  31. package/src/providers/MessagingProvider.tsx +24 -5
  32. package/src/testing/createMockMessagingClient.stories.tsx +77 -0
  33. package/src/testing/createMockMessagingClient.ts +231 -0
  34. package/src/testing/index.ts +7 -0
  35. package/src/types.ts +15 -2
  36. package/dist/index-BPfehHDD.js.map +0 -1
  37. package/dist/index-G4pnfm4m.cjs +0 -2
  38. package/dist/index-G4pnfm4m.cjs.map +0 -1
  39. /package/dist/assets/{index.css → messaging-react.css} +0 -0
package/dist/index.cjs CHANGED
@@ -1,2 +1,2 @@
1
- "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("./index-G4pnfm4m.cjs");exports.ActionButton=e.ActionButton;exports.Avatar=e.Avatar;exports.ChannelEmptyState=e.ChannelEmptyState;exports.ChannelList=e.ChannelList;exports.ChannelView=e.ChannelView;exports.CustomMessageProvider=e.CustomMessageProvider;exports.FaqList=e.FaqList;exports.FaqListItem=e.FaqListItem;exports.LinkAttachment=e.LinkAttachment;exports.LockedAttachment=e.LockedAttachment;exports.MediaMessage=e.MediaMessage;exports.MessageAttachment=e.MessageAttachment;exports.MessageVoteButtons=e.MessageVoteButtons;exports.MessagingProvider=e.MessagingProvider;exports.MessagingShell=e.MessagingShell;exports.buildCompactMetaLabel=e.buildCompactMetaLabel;exports.formatFileSize=e.formatFileSize;exports.formatRelativeTime=e.formatRelativeTime;exports.getFileExtensionLabel=e.getFileExtensionLabel;exports.getMessageDisplayText=e.getMessageDisplayText;exports.isLinkAttachment=e.isLinkAttachment;exports.isUuidLike=e.isUuidLike;exports.messageAttachmentGroupPositionFromStream=e.bubbleGroupPositionFromStream;exports.normalizeLanguageCode=e.normalizeLanguageCode;exports.resolveConversationParticipant=e.resolveConversationParticipant;exports.resolveLinkAttachment=e.resolveLinkAttachment;exports.resolveMediaFromMessage=e.resolveMediaFromMessage;exports.resolveParticipantDisplayName=e.resolveParticipantDisplayName;exports.useComposerLocked=e.useComposerLocked;exports.useCustomMessage=e.useCustomMessage;exports.useMessageVote=e.useMessageVote;exports.useMessaging=e.useMessaging;
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("./index-BgzqZorr.cjs");exports.ActionButton=e.ActionButton;exports.Avatar=e.Avatar;exports.ChannelEmptyState=e.ChannelEmptyState;exports.ChannelList=e.ChannelList;exports.ChannelView=e.ChannelView;exports.CustomMessageProvider=e.CustomMessageProvider;exports.FaqList=e.FaqList;exports.FaqListItem=e.FaqListItem;exports.LinkAttachment=e.LinkAttachment;exports.LockedAttachment=e.LockedAttachment;exports.MediaMessage=e.MediaMessage;exports.MessageAttachment=e.MessageAttachment;exports.MessageVoteButtons=e.MessageVoteButtons;exports.MessagingProvider=e.MessagingProvider;exports.MessagingShell=e.MessagingShell;exports.buildCompactMetaLabel=e.buildCompactMetaLabel;exports.formatFileSize=e.formatFileSize;exports.formatRelativeTime=e.formatRelativeTime;exports.getFileExtensionLabel=e.getFileExtensionLabel;exports.getMessageDisplayText=e.getMessageDisplayText;exports.isLinkAttachment=e.isLinkAttachment;exports.isUuidLike=e.isUuidLike;exports.messageAttachmentGroupPositionFromStream=e.bubbleGroupPositionFromStream;exports.normalizeLanguageCode=e.normalizeLanguageCode;exports.resolveConversationParticipant=e.resolveConversationParticipant;exports.resolveLinkAttachment=e.resolveLinkAttachment;exports.resolveMediaFromMessage=e.resolveMediaFromMessage;exports.resolveParticipantDisplayName=e.resolveParticipantDisplayName;exports.useComposerLocked=e.useComposerLocked;exports.useCustomMessage=e.useCustomMessage;exports.useMessageVote=e.useMessageVote;exports.useMessaging=e.useMessaging;
2
2
  //# sourceMappingURL=index.cjs.map
package/dist/index.d.ts CHANGED
@@ -1237,8 +1237,20 @@ export declare const MessagingProvider: default_2.FC<MessagingProviderProps>;
1237
1237
  export declare interface MessagingProviderProps {
1238
1238
  children: React.ReactNode;
1239
1239
  user: MessagingUser | null;
1240
- serviceConfig: Omit<StreamChatServiceConfig, 'apiKey'>;
1241
- apiKey: string;
1240
+ /**
1241
+ * An already-constructed Stream client. When provided it takes precedence
1242
+ * over `apiKey`: the provider renders `<Chat>` against this exact instance
1243
+ * and does NOT create a StreamChatService or call `connectUser` — the caller
1244
+ * owns the client's connection lifecycle. This is the seam for offline/dev
1245
+ * (`createMockMessagingClient` from `@linktr.ee/messaging-react/testing`) and
1246
+ * for tests, so consumers render the real UI instead of forking it.
1247
+ * `apiKey`/`serviceConfig` are ignored while `client` is set.
1248
+ */
1249
+ client?: StreamChat;
1250
+ /** Required unless `client` is provided. */
1251
+ serviceConfig?: Omit<StreamChatServiceConfig, 'apiKey'>;
1252
+ /** Required unless `client` is provided. */
1253
+ apiKey?: string;
1242
1254
  capabilities?: MessagingCapabilities;
1243
1255
  debug?: boolean;
1244
1256
  }
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as e, b as t, C as i, c as o, d as n, e as m, F as r, f as g, L as l, h as M, M as c, i as u, j as L, k as h, l as d, m as p, n as v, o as C, p as A, q as k, s as F, t as P, u as b, v as f, w as x, x as y, y as S, z as q, B as z, D as B, E as D, G as E } from "./index-BPfehHDD.js";
1
+ import { a as e, b as t, C as i, c as o, d as n, e as m, F as r, f as g, L as l, h as M, M as c, i as u, j as L, k as h, l as d, m as p, n as v, o as C, p as A, q as k, s as F, t as P, u as b, v as f, w as x, x as y, y as S, z as q, B as z, D as B, E as D, G as E } from "./index-2HMjfjM-.js";
2
2
  export {
3
3
  e as ActionButton,
4
4
  t as Avatar,
@@ -0,0 +1,2 @@
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const M=require("stream-chat"),w=600;function b({currentUser:e,participant:a,messages:u=[],channelId:h="mock-dm",onSend:d}){const s=new M.StreamChat("mock-api-key",{allowServerSideConnect:!0});s.connectUser=async()=>({me:e}),s.disconnectUser=async()=>{},s.userID=e.id,s.user=e;const m=`messaging:${h}`,l=(t,c)=>{const r=t.from==="me"?e:a,n=new Date(Date.now()-(u.length-c)*6e4).toISOString();return{id:t.id??`mock-msg-${c}`,text:t.text??"",type:"regular",html:t.text?`<p>${t.text}</p>`:"",user:r,attachments:t.attachments??[],latest_reactions:[],own_reactions:[],reaction_counts:{},reaction_scores:{},reply_count:0,status:"received",cid:m,created_at:n,updated_at:n,mentioned_users:[],...t.metadata?{metadata:t.metadata}:{}}},i=u.map(l),f={[e.id]:{user:{...e,is_account:!1},user_id:e.id,role:"owner",is_account:!1},[a.id]:{user:{...a,is_account:!0},user_id:a.id,role:"member",is_account:!0}},o=s.channel("messaging",h,{members:[e.id,a.id]});o.watch=async()=>(o.state.messages=i,o.state.members=f,{channel:{members:[e.id,a.id]},members:[],messages:i,watchers:[],pinned_messages:[],duration:"0ms"});const y=t=>{o.state.addMessageSorted(t),s.dispatchEvent({type:"message.new",cid:m,message:t,user:t.user??void 0})};let _=0;return o.sendMessage=async t=>{const c=typeof t=="string"?t:(t==null?void 0:t.text)??"",r={...l({text:c,from:"me",id:`mock-sent-${_++}`},i.length),...typeof t=="object"?t:{},user:e,cid:m};y(r);const n=d==null?void 0:d(c);if(n){const g=typeof n=="string"?n:n.text,p=typeof n=="string"?void 0:n.attachments;setTimeout(()=>{y(l({text:g,from:"them",attachments:p,id:`mock-reply-${_}`},i.length))},w)}return{message:r}},s.queryChannels=async()=>[o],o.watch(),{client:s,channel:o,participantFilterId:a.id}}exports.createMockMessagingClient=b;
2
+ //# sourceMappingURL=testing.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"testing.cjs","sources":["../src/testing/createMockMessagingClient.ts"],"sourcesContent":["import type {\n Channel,\n MessageResponse,\n QueryChannelAPIResponse,\n SendMessageAPIResponse,\n} from 'stream-chat'\nimport { StreamChat } from 'stream-chat'\n\n/** A participant in the mock conversation. */\nexport interface MockMessagingUser {\n id: string\n name?: string\n image?: string\n}\n\n/**\n * A seed message, authored from the viewer's perspective. `from: 'me'` is the\n * connected user (the viewer); `from: 'them'` is the other participant.\n */\nexport interface MockMessage {\n id?: string\n text?: string\n from: 'me' | 'them'\n /** Stream attachment payloads, rendered by the toolkit's attachment gate. */\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n attachments?: any[]\n /** e.g. `{ custom_type: 'MESSAGE_WELCOME' }`. */\n metadata?: Record<string, unknown>\n}\n\nexport interface CreateMockMessagingClientOptions {\n /** The connected/viewing user. */\n currentUser: MockMessagingUser\n /** The other party in the direct conversation (e.g. the creator/linker). */\n participant: MockMessagingUser\n /** Seed conversation, oldest-first. */\n messages?: MockMessage[]\n /** Channel id; defaults to `mock-dm`. */\n channelId?: string\n /**\n * Optional canned reply. Called after the viewer sends a message; return the\n * reply text (or a partial message) to have `participant` echo a response, or\n * a falsy value for no reply. Drives the \"send echo\" in `dev:mock-messaging`.\n */\n onSend?: (\n text: string\n ) => string | { text?: string; attachments?: unknown[] } | null | undefined\n}\n\nexport interface MockMessagingClient {\n /** Pass to `<MessagingProvider client={...}>`. */\n client: StreamChat\n /** The seeded direct-conversation channel. */\n channel: Channel\n /** Pass to `<MessagingShell initialParticipantFilter={...}>`. */\n participantFilterId: string\n}\n\nconst REPLY_DELAY_MS = 600\n\n/**\n * Builds an offline Stream client seeded with a single direct conversation, so\n * consumers can render the **real** messaging UI (MessagingProvider →\n * MessagingShell → ChannelView) without a Stream backend — for `dev:mock-*`\n * surfaces and Storybook. It is a real `StreamChat` whose network calls\n * (`connectUser`, `queryChannels`, `channel.watch`, `channel.sendMessage`) are\n * replaced with in-memory behaviour driven by Stream's own channel-state\n * machinery, so rendering fidelity matches production.\n *\n * Ships from `@linktr.ee/messaging-react/testing` and must never be imported by\n * production code.\n */\nexport function createMockMessagingClient({\n currentUser,\n participant,\n messages = [],\n channelId = 'mock-dm',\n onSend,\n}: CreateMockMessagingClientOptions): MockMessagingClient {\n const client = new StreamChat('mock-api-key', {\n allowServerSideConnect: true,\n })\n\n // Mark connected without opening a WebSocket. `MessagingProvider` renders\n // `<Chat client={client}>` directly for an injected client, so it never\n // calls connectUser itself.\n client.connectUser = async () => ({ me: currentUser }) as never\n client.disconnectUser = async () => undefined\n client.userID = currentUser.id\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n client.user = currentUser as any\n\n const cid = `messaging:${channelId}`\n\n const toStreamMessage = (\n message: MockMessage,\n index: number\n ): MessageResponse => {\n const author = message.from === 'me' ? currentUser : participant\n // Stagger timestamps oldest-first so the list orders naturally.\n const createdAt = new Date(\n Date.now() - (messages.length - index) * 60_000\n ).toISOString()\n return {\n id: message.id ?? `mock-msg-${index}`,\n text: message.text ?? '',\n type: 'regular',\n html: message.text ? `<p>${message.text}</p>` : '',\n user: author,\n attachments: message.attachments ?? [],\n latest_reactions: [],\n own_reactions: [],\n reaction_counts: {},\n reaction_scores: {},\n reply_count: 0,\n status: 'received',\n cid,\n created_at: createdAt,\n updated_at: createdAt,\n mentioned_users: [],\n ...(message.metadata ? { metadata: message.metadata } : {}),\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n } as any\n }\n\n const seededMessages = messages.map(toStreamMessage)\n\n const memberState = {\n [currentUser.id]: {\n user: { ...currentUser, is_account: false },\n user_id: currentUser.id,\n role: 'owner',\n is_account: false,\n },\n [participant.id]: {\n user: { ...participant, is_account: true },\n user_id: participant.id,\n role: 'member',\n is_account: true,\n },\n }\n\n const channel = client.channel('messaging', channelId, {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n members: [currentUser.id, participant.id] as any,\n })\n\n // Seed state locally instead of fetching. Mirrors the proven ChannelView\n // story mock: override `watch` to populate `state` and return a canned\n // QueryChannelAPIResponse.\n channel.watch = async () => {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n channel.state.messages = seededMessages as unknown as any[]\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n channel.state.members = memberState as unknown as any\n return {\n channel: { members: [currentUser.id, participant.id] },\n members: [],\n messages: seededMessages,\n watchers: [],\n pinned_messages: [],\n duration: '0ms',\n } as unknown as QueryChannelAPIResponse\n }\n\n const appendMessage = (message: MessageResponse) => {\n // addMessageSorted is Stream's own state mutation (dedupes by id), so the\n // toolkit re-renders through the real channel-state path.\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n channel.state.addMessageSorted(message as any)\n client.dispatchEvent({\n type: 'message.new',\n cid,\n message,\n user: message.user ?? undefined,\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n } as any)\n }\n\n let sentCount = 0\n channel.sendMessage = async (message) => {\n const text =\n typeof message === 'string'\n ? message\n : ((message as { text?: string })?.text ?? '')\n const sent = {\n ...toStreamMessage({ text, from: 'me', id: `mock-sent-${sentCount++}` }, seededMessages.length),\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n ...(typeof message === 'object' ? (message as any) : {}),\n user: currentUser,\n cid,\n } as MessageResponse\n\n // stream-chat-react adds the outgoing message optimistically; addMessageSorted\n // dedupes by id, so confirming here is safe and idempotent.\n appendMessage(sent)\n\n const reply = onSend?.(text)\n if (reply) {\n const replyText = typeof reply === 'string' ? reply : reply.text\n const replyAttachments =\n typeof reply === 'string' ? undefined : reply.attachments\n setTimeout(() => {\n appendMessage(\n toStreamMessage(\n {\n text: replyText,\n from: 'them',\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n attachments: replyAttachments as any,\n id: `mock-reply-${sentCount}`,\n },\n seededMessages.length\n )\n )\n }, REPLY_DELAY_MS)\n }\n\n return { message: sent } as unknown as SendMessageAPIResponse\n }\n\n // MessagingShell finds the direct conversation via queryChannels; always\n // return the one seeded channel regardless of the filter.\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n client.queryChannels = async () => [channel] as any\n\n // Initialise state up-front so first render already has the conversation.\n void channel.watch()\n\n return { client, channel, participantFilterId: participant.id }\n}\n"],"names":["REPLY_DELAY_MS","createMockMessagingClient","currentUser","participant","messages","channelId","onSend","client","StreamChat","cid","toStreamMessage","message","index","author","createdAt","seededMessages","memberState","channel","appendMessage","sentCount","text","sent","reply","replyText","replyAttachments"],"mappings":"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"}
@@ -0,0 +1,116 @@
1
+ import { Channel } from 'stream-chat';
2
+ import { StreamChat } from 'stream-chat';
3
+
4
+ /**
5
+ * Builds an offline Stream client seeded with a single direct conversation, so
6
+ * consumers can render the **real** messaging UI (MessagingProvider →
7
+ * MessagingShell → ChannelView) without a Stream backend — for `dev:mock-*`
8
+ * surfaces and Storybook. It is a real `StreamChat` whose network calls
9
+ * (`connectUser`, `queryChannels`, `channel.watch`, `channel.sendMessage`) are
10
+ * replaced with in-memory behaviour driven by Stream's own channel-state
11
+ * machinery, so rendering fidelity matches production.
12
+ *
13
+ * Ships from `@linktr.ee/messaging-react/testing` and must never be imported by
14
+ * production code.
15
+ */
16
+ export declare function createMockMessagingClient({ currentUser, participant, messages, channelId, onSend, }: CreateMockMessagingClientOptions): MockMessagingClient;
17
+
18
+ export declare interface CreateMockMessagingClientOptions {
19
+ /** The connected/viewing user. */
20
+ currentUser: MockMessagingUser;
21
+ /** The other party in the direct conversation (e.g. the creator/linker). */
22
+ participant: MockMessagingUser;
23
+ /** Seed conversation, oldest-first. */
24
+ messages?: MockMessage[];
25
+ /** Channel id; defaults to `mock-dm`. */
26
+ channelId?: string;
27
+ /**
28
+ * Optional canned reply. Called after the viewer sends a message; return the
29
+ * reply text (or a partial message) to have `participant` echo a response, or
30
+ * a falsy value for no reply. Drives the "send echo" in `dev:mock-messaging`.
31
+ */
32
+ onSend?: (text: string) => string | {
33
+ text?: string;
34
+ attachments?: unknown[];
35
+ } | null | undefined;
36
+ }
37
+
38
+ /**
39
+ * A seed message, authored from the viewer's perspective. `from: 'me'` is the
40
+ * connected user (the viewer); `from: 'them'` is the other participant.
41
+ */
42
+ export declare interface MockMessage {
43
+ id?: string;
44
+ text?: string;
45
+ from: 'me' | 'them';
46
+ /** Stream attachment payloads, rendered by the toolkit's attachment gate. */
47
+ attachments?: any[];
48
+ /** e.g. `{ custom_type: 'MESSAGE_WELCOME' }`. */
49
+ metadata?: Record<string, unknown>;
50
+ }
51
+
52
+ export declare interface MockMessagingClient {
53
+ /** Pass to `<MessagingProvider client={...}>`. */
54
+ client: StreamChat;
55
+ /** The seeded direct-conversation channel. */
56
+ channel: Channel;
57
+ /** Pass to `<MessagingShell initialParticipantFilter={...}>`. */
58
+ participantFilterId: string;
59
+ }
60
+
61
+ /** A participant in the mock conversation. */
62
+ export declare interface MockMessagingUser {
63
+ id: string;
64
+ name?: string;
65
+ image?: string;
66
+ }
67
+
68
+ export { }
69
+
70
+
71
+ declare module 'stream-chat' {
72
+ interface CustomAttachmentData {
73
+ action?: OfficialCtaActionPayload;
74
+ }
75
+ interface CustomChannelData {
76
+ /**
77
+ * Whether the channel has at least one visitor-originated message.
78
+ * Legacy channels may omit this field.
79
+ */
80
+ has_visitor_message?: boolean;
81
+ /**
82
+ * Priority score assigned by backend classification for spotlighting.
83
+ */
84
+ priority_score?: number;
85
+ /**
86
+ * Indicates a paid message is pending creator reply.
87
+ */
88
+ pending_earn_message_reply?: boolean;
89
+ /**
90
+ * Indicates DM agent has deferred and creator follow-up is needed.
91
+ */
92
+ agent_deferred?: boolean;
93
+ /**
94
+ * Whether DM agent auto-replies are paused for this channel.
95
+ */
96
+ chatbot_paused?: boolean;
97
+ }
98
+ interface CustomMessageData {
99
+ /**
100
+ * When true, hides the date timestamp in system messages.
101
+ * Used by CustomSystemMessage component.
102
+ */
103
+ hide_date?: boolean;
104
+ /**
105
+ * Message metadata from backend.
106
+ * Contains type and payment information.
107
+ */
108
+ metadata?: MessageMetadata;
109
+ /**
110
+ * DM Agent-specific system message type.
111
+ * Used as a fallback when metadata.custom_type is not available.
112
+ */
113
+ dm_agent_system_type?: DmAgentSystemType;
114
+ }
115
+ }
116
+
@@ -0,0 +1,107 @@
1
+ import { StreamChat as x } from "stream-chat";
2
+ const M = 600;
3
+ function g({
4
+ currentUser: e,
5
+ participant: c,
6
+ messages: u = [],
7
+ channelId: h = "mock-dm",
8
+ onSend: r
9
+ }) {
10
+ const n = new x("mock-api-key", {
11
+ allowServerSideConnect: !0
12
+ });
13
+ n.connectUser = async () => ({ me: e }), n.disconnectUser = async () => {
14
+ }, n.userID = e.id, n.user = e;
15
+ const m = `messaging:${h}`, l = (t, a) => {
16
+ const d = t.from === "me" ? e : c, o = new Date(
17
+ Date.now() - (u.length - a) * 6e4
18
+ ).toISOString();
19
+ return {
20
+ id: t.id ?? `mock-msg-${a}`,
21
+ text: t.text ?? "",
22
+ type: "regular",
23
+ html: t.text ? `<p>${t.text}</p>` : "",
24
+ user: d,
25
+ attachments: t.attachments ?? [],
26
+ latest_reactions: [],
27
+ own_reactions: [],
28
+ reaction_counts: {},
29
+ reaction_scores: {},
30
+ reply_count: 0,
31
+ status: "received",
32
+ cid: m,
33
+ created_at: o,
34
+ updated_at: o,
35
+ mentioned_users: [],
36
+ ...t.metadata ? { metadata: t.metadata } : {}
37
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
38
+ };
39
+ }, i = u.map(l), f = {
40
+ [e.id]: {
41
+ user: { ...e, is_account: !1 },
42
+ user_id: e.id,
43
+ role: "owner",
44
+ is_account: !1
45
+ },
46
+ [c.id]: {
47
+ user: { ...c, is_account: !0 },
48
+ user_id: c.id,
49
+ role: "member",
50
+ is_account: !0
51
+ }
52
+ }, s = n.channel("messaging", h, {
53
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
54
+ members: [e.id, c.id]
55
+ });
56
+ s.watch = async () => (s.state.messages = i, s.state.members = f, {
57
+ channel: { members: [e.id, c.id] },
58
+ members: [],
59
+ messages: i,
60
+ watchers: [],
61
+ pinned_messages: [],
62
+ duration: "0ms"
63
+ });
64
+ const y = (t) => {
65
+ s.state.addMessageSorted(t), n.dispatchEvent({
66
+ type: "message.new",
67
+ cid: m,
68
+ message: t,
69
+ user: t.user ?? void 0
70
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
71
+ });
72
+ };
73
+ let _ = 0;
74
+ return s.sendMessage = async (t) => {
75
+ const a = typeof t == "string" ? t : (t == null ? void 0 : t.text) ?? "", d = {
76
+ ...l({ text: a, from: "me", id: `mock-sent-${_++}` }, i.length),
77
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
78
+ ...typeof t == "object" ? t : {},
79
+ user: e,
80
+ cid: m
81
+ };
82
+ y(d);
83
+ const o = r == null ? void 0 : r(a);
84
+ if (o) {
85
+ const p = typeof o == "string" ? o : o.text, w = typeof o == "string" ? void 0 : o.attachments;
86
+ setTimeout(() => {
87
+ y(
88
+ l(
89
+ {
90
+ text: p,
91
+ from: "them",
92
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
93
+ attachments: w,
94
+ id: `mock-reply-${_}`
95
+ },
96
+ i.length
97
+ )
98
+ );
99
+ }, M);
100
+ }
101
+ return { message: d };
102
+ }, n.queryChannels = async () => [s], s.watch(), { client: n, channel: s, participantFilterId: c.id };
103
+ }
104
+ export {
105
+ g as createMockMessagingClient
106
+ };
107
+ //# sourceMappingURL=testing.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"testing.js","sources":["../src/testing/createMockMessagingClient.ts"],"sourcesContent":["import type {\n Channel,\n MessageResponse,\n QueryChannelAPIResponse,\n SendMessageAPIResponse,\n} from 'stream-chat'\nimport { StreamChat } from 'stream-chat'\n\n/** A participant in the mock conversation. */\nexport interface MockMessagingUser {\n id: string\n name?: string\n image?: string\n}\n\n/**\n * A seed message, authored from the viewer's perspective. `from: 'me'` is the\n * connected user (the viewer); `from: 'them'` is the other participant.\n */\nexport interface MockMessage {\n id?: string\n text?: string\n from: 'me' | 'them'\n /** Stream attachment payloads, rendered by the toolkit's attachment gate. */\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n attachments?: any[]\n /** e.g. `{ custom_type: 'MESSAGE_WELCOME' }`. */\n metadata?: Record<string, unknown>\n}\n\nexport interface CreateMockMessagingClientOptions {\n /** The connected/viewing user. */\n currentUser: MockMessagingUser\n /** The other party in the direct conversation (e.g. the creator/linker). */\n participant: MockMessagingUser\n /** Seed conversation, oldest-first. */\n messages?: MockMessage[]\n /** Channel id; defaults to `mock-dm`. */\n channelId?: string\n /**\n * Optional canned reply. Called after the viewer sends a message; return the\n * reply text (or a partial message) to have `participant` echo a response, or\n * a falsy value for no reply. Drives the \"send echo\" in `dev:mock-messaging`.\n */\n onSend?: (\n text: string\n ) => string | { text?: string; attachments?: unknown[] } | null | undefined\n}\n\nexport interface MockMessagingClient {\n /** Pass to `<MessagingProvider client={...}>`. */\n client: StreamChat\n /** The seeded direct-conversation channel. */\n channel: Channel\n /** Pass to `<MessagingShell initialParticipantFilter={...}>`. */\n participantFilterId: string\n}\n\nconst REPLY_DELAY_MS = 600\n\n/**\n * Builds an offline Stream client seeded with a single direct conversation, so\n * consumers can render the **real** messaging UI (MessagingProvider →\n * MessagingShell → ChannelView) without a Stream backend — for `dev:mock-*`\n * surfaces and Storybook. It is a real `StreamChat` whose network calls\n * (`connectUser`, `queryChannels`, `channel.watch`, `channel.sendMessage`) are\n * replaced with in-memory behaviour driven by Stream's own channel-state\n * machinery, so rendering fidelity matches production.\n *\n * Ships from `@linktr.ee/messaging-react/testing` and must never be imported by\n * production code.\n */\nexport function createMockMessagingClient({\n currentUser,\n participant,\n messages = [],\n channelId = 'mock-dm',\n onSend,\n}: CreateMockMessagingClientOptions): MockMessagingClient {\n const client = new StreamChat('mock-api-key', {\n allowServerSideConnect: true,\n })\n\n // Mark connected without opening a WebSocket. `MessagingProvider` renders\n // `<Chat client={client}>` directly for an injected client, so it never\n // calls connectUser itself.\n client.connectUser = async () => ({ me: currentUser }) as never\n client.disconnectUser = async () => undefined\n client.userID = currentUser.id\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n client.user = currentUser as any\n\n const cid = `messaging:${channelId}`\n\n const toStreamMessage = (\n message: MockMessage,\n index: number\n ): MessageResponse => {\n const author = message.from === 'me' ? currentUser : participant\n // Stagger timestamps oldest-first so the list orders naturally.\n const createdAt = new Date(\n Date.now() - (messages.length - index) * 60_000\n ).toISOString()\n return {\n id: message.id ?? `mock-msg-${index}`,\n text: message.text ?? '',\n type: 'regular',\n html: message.text ? `<p>${message.text}</p>` : '',\n user: author,\n attachments: message.attachments ?? [],\n latest_reactions: [],\n own_reactions: [],\n reaction_counts: {},\n reaction_scores: {},\n reply_count: 0,\n status: 'received',\n cid,\n created_at: createdAt,\n updated_at: createdAt,\n mentioned_users: [],\n ...(message.metadata ? { metadata: message.metadata } : {}),\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n } as any\n }\n\n const seededMessages = messages.map(toStreamMessage)\n\n const memberState = {\n [currentUser.id]: {\n user: { ...currentUser, is_account: false },\n user_id: currentUser.id,\n role: 'owner',\n is_account: false,\n },\n [participant.id]: {\n user: { ...participant, is_account: true },\n user_id: participant.id,\n role: 'member',\n is_account: true,\n },\n }\n\n const channel = client.channel('messaging', channelId, {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n members: [currentUser.id, participant.id] as any,\n })\n\n // Seed state locally instead of fetching. Mirrors the proven ChannelView\n // story mock: override `watch` to populate `state` and return a canned\n // QueryChannelAPIResponse.\n channel.watch = async () => {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n channel.state.messages = seededMessages as unknown as any[]\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n channel.state.members = memberState as unknown as any\n return {\n channel: { members: [currentUser.id, participant.id] },\n members: [],\n messages: seededMessages,\n watchers: [],\n pinned_messages: [],\n duration: '0ms',\n } as unknown as QueryChannelAPIResponse\n }\n\n const appendMessage = (message: MessageResponse) => {\n // addMessageSorted is Stream's own state mutation (dedupes by id), so the\n // toolkit re-renders through the real channel-state path.\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n channel.state.addMessageSorted(message as any)\n client.dispatchEvent({\n type: 'message.new',\n cid,\n message,\n user: message.user ?? undefined,\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n } as any)\n }\n\n let sentCount = 0\n channel.sendMessage = async (message) => {\n const text =\n typeof message === 'string'\n ? message\n : ((message as { text?: string })?.text ?? '')\n const sent = {\n ...toStreamMessage({ text, from: 'me', id: `mock-sent-${sentCount++}` }, seededMessages.length),\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n ...(typeof message === 'object' ? (message as any) : {}),\n user: currentUser,\n cid,\n } as MessageResponse\n\n // stream-chat-react adds the outgoing message optimistically; addMessageSorted\n // dedupes by id, so confirming here is safe and idempotent.\n appendMessage(sent)\n\n const reply = onSend?.(text)\n if (reply) {\n const replyText = typeof reply === 'string' ? reply : reply.text\n const replyAttachments =\n typeof reply === 'string' ? undefined : reply.attachments\n setTimeout(() => {\n appendMessage(\n toStreamMessage(\n {\n text: replyText,\n from: 'them',\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n attachments: replyAttachments as any,\n id: `mock-reply-${sentCount}`,\n },\n seededMessages.length\n )\n )\n }, REPLY_DELAY_MS)\n }\n\n return { message: sent } as unknown as SendMessageAPIResponse\n }\n\n // MessagingShell finds the direct conversation via queryChannels; always\n // return the one seeded channel regardless of the filter.\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n client.queryChannels = async () => [channel] as any\n\n // Initialise state up-front so first render already has the conversation.\n void channel.watch()\n\n return { client, channel, participantFilterId: participant.id }\n}\n"],"names":["REPLY_DELAY_MS","createMockMessagingClient","currentUser","participant","messages","channelId","onSend","client","StreamChat","cid","toStreamMessage","message","index","author","createdAt","seededMessages","memberState","channel","appendMessage","sentCount","text","sent","reply","replyText","replyAttachments"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linktr.ee/messaging-react",
3
- "version": "3.6.1",
3
+ "version": "3.7.0-rc-1783304153",
4
4
  "description": "React messaging components built on messaging-core for web applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -12,6 +12,11 @@
12
12
  "import": "./dist/index.js",
13
13
  "require": "./dist/index.cjs"
14
14
  },
15
+ "./testing": {
16
+ "types": "./dist/testing.d.ts",
17
+ "import": "./dist/testing.js",
18
+ "require": "./dist/testing.cjs"
19
+ },
15
20
  "./styles.css": "./dist/assets/index.css"
16
21
  },
17
22
  "license": "UNLICENSED",
@@ -38,7 +43,7 @@
38
43
  },
39
44
  "dependencies": {
40
45
  "@linktr.ee/component-library": "11.8.6",
41
- "@linktr.ee/messaging-core": "^2.2.0",
46
+ "@linktr.ee/messaging-core": "2.3.0-rc-1783304153",
42
47
  "@phosphor-icons/react": "^2.1.10"
43
48
  },
44
49
  "devDependencies": {
@@ -258,4 +258,34 @@ describe('MessagingProvider', () => {
258
258
  await waitFor(() => expect(connectUser).toHaveBeenCalledTimes(2))
259
259
  expect(connectUser).toHaveBeenLastCalledWith(authedUser)
260
260
  })
261
+
262
+ it('uses an injected client directly and never connects a service', async () => {
263
+ const { connectUser, disconnectUser } = setupServiceMock()
264
+ const injectedClient = {
265
+ userID: 'injected-user',
266
+ } as unknown as StreamChat
267
+ const states: unknown[] = []
268
+
269
+ render(
270
+ <MessagingProvider client={injectedClient} user={null}>
271
+ <Probe onState={(s) => states.push(s)} />
272
+ </MessagingProvider>
273
+ )
274
+
275
+ // Injected client is reported connected immediately, and its instance is
276
+ // surfaced on the context.
277
+ await waitFor(() => {
278
+ const last = states[states.length - 1] as {
279
+ isConnected: boolean
280
+ hasClient: boolean
281
+ }
282
+ expect(last.isConnected).toBe(true)
283
+ expect(last.hasClient).toBe(true)
284
+ })
285
+
286
+ // The caller owns the injected client's lifecycle — the provider must not
287
+ // spin up a StreamChatService or connect/disconnect it.
288
+ expect(connectUser).not.toHaveBeenCalled()
289
+ expect(disconnectUser).not.toHaveBeenCalled()
290
+ })
261
291
  })
@@ -52,6 +52,7 @@ export const MessagingProvider: React.FC<MessagingProviderProps> = ({
52
52
  apiKey,
53
53
  capabilities = {},
54
54
  debug = false,
55
+ client: injectedClient,
55
56
  }) => {
56
57
  // Create debug logger that respects the debug prop
57
58
  const debugLog = useCallback(
@@ -137,10 +138,18 @@ export const MessagingProvider: React.FC<MessagingProviderProps> = ({
137
138
  // not the process-wide StreamChat.getInstance, which is shared across keys and
138
139
  // provider instances.
139
140
  const clientRef = useRef<{ apiKey: string; client: StreamChat } | null>(null)
140
- if (apiKey && clientRef.current?.apiKey !== apiKey) {
141
+ if (!injectedClient && apiKey && clientRef.current?.apiKey !== apiKey) {
141
142
  clientRef.current = { apiKey, client: new StreamChat(apiKey) }
142
143
  }
143
- const chatClient = apiKey ? (clientRef.current?.client ?? null) : null
144
+ // An injected client takes precedence and short-circuits the apiKey-derived
145
+ // client, the service, and the connect/disconnect lifecycle below — the
146
+ // caller owns that instance (offline/dev via `createMockMessagingClient`, or
147
+ // a test). Otherwise fall back to the apiKey-derived client as before.
148
+ const chatClient = injectedClient
149
+ ? injectedClient
150
+ : apiKey
151
+ ? (clientRef.current?.client ?? null)
152
+ : null
144
153
 
145
154
  // Create the service once per client lifetime (apiKey). Keyed on `chatClient`
146
155
  // — NOT on serviceConfig/debug — so those changing does not spin up a second
@@ -152,6 +161,12 @@ export const MessagingProvider: React.FC<MessagingProviderProps> = ({
152
161
  const currentRender = renderCountRef.current
153
162
  const currentConfig = serviceConfigRef.current
154
163
 
164
+ // An injected client owns its own connection — never create a service or
165
+ // connect for it.
166
+ if (injectedClient) {
167
+ return
168
+ }
169
+
155
170
  if (!apiKey || !chatClient || !currentConfig) {
156
171
  debugLog('⚠️ SERVICE INIT SKIPPED', {
157
172
  renderCount: currentRender,
@@ -182,7 +197,7 @@ export const MessagingProvider: React.FC<MessagingProviderProps> = ({
182
197
  // No disconnect on cleanup here — disconnection is owned by the
183
198
  // client-cleanup effect below, keyed on `chatClient` (apiKey) and unmount.
184
199
  // eslint-disable-next-line react-hooks/exhaustive-deps -- serviceConfig/debug/debugLog are read via refs by design (see above); recreating on them would re-adopt the live client
185
- }, [apiKey, chatClient])
200
+ }, [apiKey, chatClient, injectedClient])
186
201
 
187
202
  // Track if we've already connected this user with this service to prevent duplicate connections
188
203
  const connectedUserRef = useRef<{
@@ -324,8 +339,11 @@ export const MessagingProvider: React.FC<MessagingProviderProps> = ({
324
339
 
325
340
  return {
326
341
  service,
327
- client,
328
- isConnected,
342
+ // An injected client is already connected and owns its lifecycle, so
343
+ // surface it directly and report connected — the apiKey path's `client`
344
+ // state (set by connectUser) is never populated in that mode.
345
+ client: injectedClient ?? client,
346
+ isConnected: injectedClient ? true : isConnected,
329
347
  isLoading,
330
348
  error,
331
349
  capabilities,
@@ -335,6 +353,7 @@ export const MessagingProvider: React.FC<MessagingProviderProps> = ({
335
353
  }, [
336
354
  service,
337
355
  client,
356
+ injectedClient,
338
357
  isConnected,
339
358
  isLoading,
340
359
  error,
@@ -0,0 +1,77 @@
1
+ import type { Meta, StoryFn } from '@storybook/react'
2
+ import React from 'react'
3
+
4
+ import { MessagingShell } from '../components/MessagingShell'
5
+ import { MessagingProvider } from '../providers/MessagingProvider'
6
+
7
+ import { createMockMessagingClient } from './createMockMessagingClient'
8
+
9
+ /**
10
+ * Exercises the real MessagingProvider → MessagingShell → ChannelView stack
11
+ * against an injected mock client (the `dev:mock-messaging` path). Use this to
12
+ * visually verify send-echo, which the unit tests can't cover because they stub
13
+ * stream-chat-react.
14
+ */
15
+ const meta: Meta = {
16
+ title: 'testing/createMockMessagingClient',
17
+ parameters: { layout: 'fullscreen' },
18
+ }
19
+ export default meta
20
+
21
+ const currentUser = { id: 'viewer-1', name: 'You' }
22
+ const participant = {
23
+ id: 'creator-1',
24
+ name: 'Ava Creator',
25
+ image: 'https://i.pravatar.cc/150?img=5',
26
+ }
27
+
28
+ const Template: StoryFn = () => {
29
+ const [mock] = React.useState(() =>
30
+ createMockMessagingClient({
31
+ currentUser,
32
+ participant,
33
+ messages: [
34
+ {
35
+ id: 'w1',
36
+ from: 'them',
37
+ text: 'Welcome! Thanks for reaching out 👋',
38
+ metadata: { custom_type: 'MESSAGE_WELCOME' },
39
+ },
40
+ {
41
+ id: 'l1',
42
+ from: 'them',
43
+ text: 'Check out my latest drop:',
44
+ attachments: [
45
+ {
46
+ type: 'image',
47
+ og_scrape_url: 'https://tr.ee/example',
48
+ title: 'My latest drop',
49
+ text: 'Limited edition',
50
+ image_url: 'https://picsum.photos/seed/mockdrop/560/360',
51
+ },
52
+ ],
53
+ },
54
+ { id: 'm1', from: 'me', text: 'Love it — just grabbed one!' },
55
+ ],
56
+ onSend: (text) => `Thanks for saying "${text}"! 🎉`,
57
+ })
58
+ )
59
+
60
+ return (
61
+ <MessagingProvider client={mock.client} user={null}>
62
+ <div className="h-screen w-full bg-[#FBFAF9]">
63
+ <MessagingShell initialParticipantFilter={mock.participantFilterId} />
64
+ </div>
65
+ </MessagingProvider>
66
+ )
67
+ }
68
+
69
+ export const Default: StoryFn = Template.bind({})
70
+ Default.parameters = {
71
+ docs: {
72
+ description: {
73
+ story:
74
+ 'Seeded direct conversation rendered through the real shell. Type a message and send it — the mock echoes a canned reply (send-echo).',
75
+ },
76
+ },
77
+ }