@linktr.ee/messaging-react 1.33.1 → 1.33.2-rc-1777444067

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.
@@ -0,0 +1,214 @@
1
+ import type { Meta, StoryFn } from '@storybook/react'
2
+ import React, { useEffect, useState } from 'react'
3
+ import { StreamChat } from 'stream-chat'
4
+
5
+ import { MessagingProvider, useMessagingContext } from './MessagingProvider'
6
+
7
+ /**
8
+ * Storybook coverage for guest-mode connections through MessagingProvider.
9
+ *
10
+ * The real Stream Chat backend is not reachable from Storybook, so we stub the
11
+ * pieces of `StreamChat.prototype` that `StreamChatService` exercises during
12
+ * `connectUser`/`disconnectUser`. This keeps the story focused on the new API
13
+ * surface (`user.type === 'guest'` + `fetchToken: undefined`) rather than on
14
+ * setting up a fake transport layer.
15
+ */
16
+ const useStubbedStreamChat = () => {
17
+ useEffect(() => {
18
+ const proto = StreamChat.prototype as unknown as Record<string, unknown>
19
+ const original = {
20
+ connectUser: proto.connectUser,
21
+ setGuestUser: proto.setGuestUser,
22
+ disconnectUser: proto.disconnectUser,
23
+ on: proto.on,
24
+ off: proto.off,
25
+ }
26
+
27
+ proto.connectUser = function connectUser(this: StreamChat) {
28
+ this.userID = 'default-stub'
29
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
30
+ ;(this as any).user = { id: 'default-stub', name: 'User' }
31
+ return Promise.resolve({ me: this.user }) as ReturnType<
32
+ typeof StreamChat.prototype.connectUser
33
+ >
34
+ } as typeof StreamChat.prototype.connectUser
35
+
36
+ proto.setGuestUser = function setGuestUser(
37
+ this: StreamChat,
38
+ user: { id: string; name?: string }
39
+ ) {
40
+ this.userID = user.id
41
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
42
+ ;(this as any).user = user
43
+ return Promise.resolve()
44
+ } as typeof StreamChat.prototype.setGuestUser
45
+
46
+ proto.disconnectUser = function disconnectUser(this: StreamChat) {
47
+ this.userID = undefined
48
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
49
+ ;(this as any).user = undefined
50
+ return Promise.resolve()
51
+ } as typeof StreamChat.prototype.disconnectUser
52
+
53
+ return () => {
54
+ Object.assign(proto, original)
55
+ }
56
+ }, [])
57
+ }
58
+
59
+ const ConnectionStatus: React.FC = () => {
60
+ const { client, isConnected, isLoading, error } = useMessagingContext()
61
+ return (
62
+ <div className="flex flex-col gap-1 rounded border border-gray-300 bg-white p-4 text-sm">
63
+ <div>
64
+ <strong>isLoading:</strong> {String(isLoading)}
65
+ </div>
66
+ <div>
67
+ <strong>isConnected:</strong> {String(isConnected)}
68
+ </div>
69
+ <div>
70
+ <strong>userID:</strong> {client?.userID ?? 'none'}
71
+ </div>
72
+ <div>
73
+ <strong>error:</strong> {error ?? 'none'}
74
+ </div>
75
+ </div>
76
+ )
77
+ }
78
+
79
+ type StoryArgs = {
80
+ mode: 'guest' | 'default'
81
+ }
82
+
83
+ const meta: Meta<StoryArgs> = {
84
+ title: 'Providers/MessagingProvider',
85
+ parameters: {
86
+ layout: 'centered',
87
+ },
88
+ argTypes: {
89
+ mode: {
90
+ control: { type: 'inline-radio' },
91
+ options: ['guest', 'default'],
92
+ },
93
+ },
94
+ }
95
+ export default meta
96
+
97
+ const Template: StoryFn<StoryArgs> = ({ mode }) => {
98
+ useStubbedStreamChat()
99
+
100
+ // Force a remount when `mode` changes so MessagingProvider tears down its
101
+ // existing connection — the documented way to swap between default and
102
+ // guest identities.
103
+ const [providerKey, setProviderKey] = useState(0)
104
+ useEffect(() => {
105
+ setProviderKey((k) => k + 1)
106
+ }, [mode])
107
+
108
+ const user =
109
+ mode === 'guest'
110
+ ? { type: 'guest' as const, id: 'guest-demo', name: 'Guest' }
111
+ : { id: 'user-demo', name: 'Authed User' }
112
+
113
+ // For guest mode `fetchToken` is intentionally omitted to demonstrate that
114
+ // `StreamChatServiceConfig.fetchToken` is no longer required.
115
+ const serviceConfig =
116
+ mode === 'guest'
117
+ ? {
118
+ createChannel: async () => ({ channelId: 'noop' }),
119
+ }
120
+ : {
121
+ fetchToken: async () => 'mock-token',
122
+ createChannel: async () => ({ channelId: 'noop' }),
123
+ }
124
+
125
+ return (
126
+ <MessagingProvider
127
+ key={`${mode}-${providerKey}`}
128
+ apiKey="mock-api-key"
129
+ user={user}
130
+ serviceConfig={serviceConfig}
131
+ >
132
+ <ConnectionStatus />
133
+ </MessagingProvider>
134
+ )
135
+ }
136
+
137
+ export const GuestMode = Template.bind({})
138
+ GuestMode.args = { mode: 'guest' }
139
+ GuestMode.parameters = {
140
+ docs: {
141
+ description: {
142
+ story:
143
+ 'Connects through `MessagingProvider` with `user={{ type: "guest", ... }}` and a service config that omits `fetchToken`. The toolkit dispatches to `StreamChat.setGuestUser` internally; no token request is made.',
144
+ },
145
+ },
146
+ }
147
+
148
+ export const DefaultMode = Template.bind({})
149
+ DefaultMode.args = { mode: 'default' }
150
+ DefaultMode.parameters = {
151
+ docs: {
152
+ description: {
153
+ story:
154
+ 'Existing behavior: `user` without a `type` connects via `client.connectUser` using the configured `fetchToken` provider.',
155
+ },
156
+ },
157
+ }
158
+
159
+ export const ModeSwap: StoryFn<StoryArgs> = () => {
160
+ useStubbedStreamChat()
161
+ const [mode, setMode] = useState<'guest' | 'default'>('guest')
162
+
163
+ const user =
164
+ mode === 'guest'
165
+ ? { type: 'guest' as const, id: 'guest-demo', name: 'Guest' }
166
+ : { id: 'user-demo', name: 'Authed User' }
167
+
168
+ const serviceConfig =
169
+ mode === 'guest'
170
+ ? {
171
+ createChannel: async () => ({ channelId: 'noop' }),
172
+ }
173
+ : {
174
+ fetchToken: async () => 'mock-token',
175
+ createChannel: async () => ({ channelId: 'noop' }),
176
+ }
177
+
178
+ return (
179
+ <div className="flex flex-col gap-3">
180
+ <div className="flex gap-2">
181
+ <button
182
+ type="button"
183
+ className="rounded bg-gray-200 px-3 py-1"
184
+ onClick={() => setMode('guest')}
185
+ >
186
+ Guest
187
+ </button>
188
+ <button
189
+ type="button"
190
+ className="rounded bg-gray-200 px-3 py-1"
191
+ onClick={() => setMode('default')}
192
+ >
193
+ Default
194
+ </button>
195
+ </div>
196
+ <MessagingProvider
197
+ key={mode}
198
+ apiKey="mock-api-key"
199
+ user={user}
200
+ serviceConfig={serviceConfig}
201
+ >
202
+ <ConnectionStatus />
203
+ </MessagingProvider>
204
+ </div>
205
+ )
206
+ }
207
+ ModeSwap.parameters = {
208
+ docs: {
209
+ description: {
210
+ story:
211
+ 'Mode swapping is consumer-driven: change the `key` on `<MessagingProvider>` so React unmounts the previous instance (which calls `disconnectUser`) and mounts a fresh one with the new identity.',
212
+ },
213
+ },
214
+ }
@@ -0,0 +1,126 @@
1
+ import { StreamChatService } from '@linktr.ee/messaging-core'
2
+ import { render, waitFor, act } from '@testing-library/react'
3
+ import React from 'react'
4
+ import type { StreamChat } from 'stream-chat'
5
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
6
+
7
+ import { MessagingProvider, useMessagingContext } from './MessagingProvider'
8
+
9
+ // Stub stream-chat-react's <Chat /> so we don't need a real, fully-wired
10
+ // StreamChat instance just to verify provider state.
11
+ vi.mock('stream-chat-react', () => ({
12
+ Chat: ({ children }: { children: React.ReactNode }) => <>{children}</>,
13
+ }))
14
+
15
+ const setupServiceMock = () => {
16
+ const connectUser = vi.fn(
17
+ async () => ({ userID: 'mock' }) as unknown as StreamChat
18
+ )
19
+ const disconnectUser = vi.fn(async () => undefined)
20
+
21
+ vi.spyOn(StreamChatService.prototype, 'connectUser').mockImplementation(
22
+ connectUser
23
+ )
24
+ vi.spyOn(StreamChatService.prototype, 'disconnectUser').mockImplementation(
25
+ disconnectUser
26
+ )
27
+
28
+ return { connectUser, disconnectUser }
29
+ }
30
+
31
+ const Probe: React.FC<{ onState: (state: unknown) => void }> = ({
32
+ onState,
33
+ }) => {
34
+ const ctx = useMessagingContext()
35
+ React.useEffect(() => {
36
+ onState({
37
+ isConnected: ctx.isConnected,
38
+ isLoading: ctx.isLoading,
39
+ error: ctx.error,
40
+ hasClient: !!ctx.client,
41
+ })
42
+ }, [ctx, onState])
43
+ return null
44
+ }
45
+
46
+ describe('MessagingProvider', () => {
47
+ beforeEach(() => {
48
+ vi.restoreAllMocks()
49
+ })
50
+
51
+ it('connects a guest user through the underlying service', async () => {
52
+ const { connectUser } = setupServiceMock()
53
+ const states: unknown[] = []
54
+
55
+ render(
56
+ <MessagingProvider
57
+ apiKey="mock-api-key"
58
+ user={{ type: 'guest', id: 'guest-1', name: 'Guest' }}
59
+ serviceConfig={{
60
+ createChannel: async () => ({ channelId: 'ch-1' }),
61
+ }}
62
+ >
63
+ <Probe onState={(s) => states.push(s)} />
64
+ </MessagingProvider>
65
+ )
66
+
67
+ await waitFor(() => expect(connectUser).toHaveBeenCalledTimes(1))
68
+ expect(connectUser).toHaveBeenCalledWith({
69
+ type: 'guest',
70
+ id: 'guest-1',
71
+ name: 'Guest',
72
+ })
73
+
74
+ await waitFor(() => {
75
+ const last = states[states.length - 1] as { isConnected: boolean }
76
+ expect(last.isConnected).toBe(true)
77
+ })
78
+ })
79
+
80
+ it('disconnects and reconnects when remounted with a new key', async () => {
81
+ const { connectUser, disconnectUser } = setupServiceMock()
82
+
83
+ const guestUser = {
84
+ type: 'guest' as const,
85
+ id: 'guest-1',
86
+ name: 'Guest',
87
+ }
88
+ const authedUser = { id: 'user-1', name: 'Authed User' }
89
+
90
+ const { rerender } = render(
91
+ <MessagingProvider
92
+ key="guest"
93
+ apiKey="mock-api-key"
94
+ user={guestUser}
95
+ serviceConfig={{
96
+ createChannel: async () => ({ channelId: 'ch-1' }),
97
+ }}
98
+ >
99
+ <div />
100
+ </MessagingProvider>
101
+ )
102
+
103
+ await waitFor(() => expect(connectUser).toHaveBeenCalledTimes(1))
104
+ expect(connectUser).toHaveBeenLastCalledWith(guestUser)
105
+
106
+ await act(async () => {
107
+ rerender(
108
+ <MessagingProvider
109
+ key="authed"
110
+ apiKey="mock-api-key"
111
+ user={authedUser}
112
+ serviceConfig={{
113
+ fetchToken: async () => 'token',
114
+ createChannel: async () => ({ channelId: 'ch-1' }),
115
+ }}
116
+ >
117
+ <div />
118
+ </MessagingProvider>
119
+ )
120
+ })
121
+
122
+ await waitFor(() => expect(disconnectUser).toHaveBeenCalled())
123
+ await waitFor(() => expect(connectUser).toHaveBeenCalledTimes(2))
124
+ expect(connectUser).toHaveBeenLastCalledWith(authedUser)
125
+ })
126
+ })
package/src/styles.css CHANGED
@@ -64,6 +64,55 @@
64
64
  }
65
65
  }
66
66
 
67
+ /* Full-screen media viewer dialog */
68
+ .mes-media-viewer {
69
+ --transition-duration: 0.15s;
70
+
71
+ border: none;
72
+ padding: 0;
73
+ margin: 0;
74
+ background: transparent;
75
+ width: 100vw;
76
+ height: 100dvh;
77
+ max-width: 100vw;
78
+ max-height: 100dvh;
79
+ position: fixed;
80
+ inset: 0;
81
+
82
+ transition:
83
+ opacity var(--transition-duration) ease,
84
+ overlay var(--transition-duration) allow-discrete,
85
+ display var(--transition-duration) allow-discrete;
86
+
87
+ opacity: 0;
88
+ }
89
+
90
+ .mes-media-viewer[open] {
91
+ opacity: 1;
92
+ }
93
+
94
+ .mes-media-viewer::backdrop {
95
+ transition:
96
+ display var(--transition-duration) allow-discrete,
97
+ overlay var(--transition-duration) allow-discrete,
98
+ background-color var(--transition-duration) linear;
99
+ background-color: rgba(0, 0, 0, 0);
100
+ }
101
+
102
+ .mes-media-viewer[open]::backdrop {
103
+ background-color: rgba(0, 0, 0, 0.92);
104
+ }
105
+
106
+ @starting-style {
107
+ .mes-media-viewer[open] {
108
+ opacity: 0;
109
+
110
+ &::backdrop {
111
+ background-color: rgba(0, 0, 0, 0);
112
+ }
113
+ }
114
+ }
115
+
67
116
  /* Textarea composer focus styles */
68
117
  .mes-textarea-composer-container:has(.mes-textarea-composer:focus) {
69
118
  outline: 2px solid black;
@@ -128,6 +177,10 @@
128
177
  color: #fff;
129
178
  }
130
179
 
180
+ .str-chat__channel-list-react .str-chat__channel-list-messenger-react {
181
+ padding: 0;
182
+ }
183
+
131
184
  /* Custom message tag styles */
132
185
  .message-tag {
133
186
  display: inline-flex;