@linktr.ee/messaging-react 1.31.0 → 1.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linktr.ee/messaging-react",
3
- "version": "1.31.0",
3
+ "version": "1.32.0",
4
4
  "description": "React messaging components built on messaging-core for web applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -39,7 +39,8 @@ const mockUser = {
39
39
  const createMockChannel = async (
40
40
  client: StreamChat,
41
41
  hasMessages = true,
42
- followerStatus?: string | boolean
42
+ followerStatus?: string | boolean,
43
+ isFrozen = false
43
44
  ) => {
44
45
  const participant = mockParticipants[0]
45
46
 
@@ -105,6 +106,7 @@ const createMockChannel = async (
105
106
  // Prepare channel data with optional follower status
106
107
  const channelData: Record<string, unknown> = {
107
108
  members: [mockUser.id, participant.id],
109
+ frozen: isFrozen,
108
110
  }
109
111
 
110
112
  // Add follower status if provided
@@ -155,10 +157,13 @@ const createMockChannel = async (
155
157
  return channel
156
158
  }
157
159
 
158
- type TemplateProps = ComponentProps & { followerStatus?: string | boolean }
160
+ type TemplateProps = ComponentProps & {
161
+ followerStatus?: string | boolean
162
+ isFrozen?: boolean
163
+ }
159
164
 
160
165
  const Template: StoryFn<TemplateProps> = (args) => {
161
- const { followerStatus, ...channelViewProps } = args
166
+ const { followerStatus, isFrozen = false, ...channelViewProps } = args
162
167
  const [client] = React.useState(() => {
163
168
  const client = new StreamChat('mock-api-key', {
164
169
  allowServerSideConnect: true,
@@ -171,10 +176,12 @@ const Template: StoryFn<TemplateProps> = (args) => {
171
176
  const [channel, setChannel] = React.useState<ChannelType | null>(null)
172
177
 
173
178
  useEffect(() => {
174
- createMockChannel(client, true, followerStatus).then((mockChannel) => {
175
- setChannel(mockChannel)
176
- })
177
- }, [client, followerStatus])
179
+ createMockChannel(client, true, followerStatus, isFrozen).then(
180
+ (mockChannel) => {
181
+ setChannel(mockChannel)
182
+ }
183
+ )
184
+ }, [client, followerStatus, isFrozen])
178
185
 
179
186
  if (!channel) {
180
187
  return <div>Loading...</div>
@@ -524,3 +531,27 @@ NoFollowerStatus.parameters = {
524
531
  },
525
532
  },
526
533
  }
534
+
535
+ export const FrozenChannel: StoryFn<TemplateProps> = Template.bind({})
536
+ FrozenChannel.args = {
537
+ showBackButton: false,
538
+ isFrozen: true,
539
+ renderMessageInputActions: (channel) => (
540
+ <button
541
+ onClick={() => console.log('Custom action clicked', channel.id)}
542
+ className="p-2 hover:bg-sand rounded-lg"
543
+ aria-label="Attach file"
544
+ type="button"
545
+ >
546
+ 📎
547
+ </button>
548
+ ),
549
+ }
550
+ FrozenChannel.parameters = {
551
+ docs: {
552
+ description: {
553
+ story:
554
+ 'Channel view for a frozen conversation. The message composer renders in its disabled frozen state while the rest of the conversation remains readable.',
555
+ },
556
+ },
557
+ }
@@ -0,0 +1,127 @@
1
+ import React from 'react'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+
4
+ import { renderWithProviders, screen } from '../../test/utils'
5
+
6
+ import { CustomMessageInput } from '.'
7
+
8
+ let mockChannelData: Record<string, unknown> = {}
9
+
10
+ vi.mock('stream-chat-react', () => ({
11
+ MessageInput: ({
12
+ Input,
13
+ }: {
14
+ Input: React.ComponentType
15
+ }) => (
16
+ <div data-testid="stream-message-input">
17
+ <Input />
18
+ </div>
19
+ ),
20
+ SimpleAttachmentSelector: () => (
21
+ <div data-testid="simple-attachment-selector" />
22
+ ),
23
+ TextareaComposer: ({
24
+ maxRows: _maxRows,
25
+ ...props
26
+ }: React.TextareaHTMLAttributes<HTMLTextAreaElement> & { maxRows?: number }) => (
27
+ <textarea data-testid="textarea-composer" {...props} />
28
+ ),
29
+ AttachmentPreviewList: () => <div data-testid="attachment-preview-list" />,
30
+ QuotedMessagePreview: () => <div data-testid="quoted-message-preview" />,
31
+ useChannelStateContext: () => ({
32
+ channel: { data: mockChannelData },
33
+ }),
34
+ useMessageInputContext: () => ({
35
+ handleSubmit: vi.fn(),
36
+ }),
37
+ useMessageComposerHasSendableData: () => false,
38
+ }))
39
+
40
+ vi.mock('../CustomLinkPreviewList', () => ({
41
+ CustomLinkPreviewList: () => <div data-testid="custom-link-preview-list" />,
42
+ }))
43
+
44
+ describe('CustomMessageInput', () => {
45
+ it('renders the interactive message input when channel is not frozen', () => {
46
+ mockChannelData = {}
47
+
48
+ const { container } = renderWithProviders(<CustomMessageInput />)
49
+
50
+ const messageInput = container.firstElementChild
51
+ expect(messageInput).not.toHaveAttribute('aria-disabled')
52
+ expect(messageInput).not.toHaveAttribute('inert')
53
+ expect(screen.getByTestId('stream-message-input')).toBeInTheDocument()
54
+ })
55
+
56
+ it('renders the frozen message input when channel is frozen', () => {
57
+ mockChannelData = { frozen: true }
58
+
59
+ const { container } = renderWithProviders(<CustomMessageInput />)
60
+
61
+ const messageInput = container.firstElementChild
62
+ expect(messageInput).toHaveAttribute('aria-disabled', 'true')
63
+ expect(messageInput).toHaveAttribute('inert')
64
+ expect(screen.getByTestId('stream-message-input')).toBeInTheDocument()
65
+ })
66
+
67
+ it('makes the textarea read-only and disables send when channel is frozen', () => {
68
+ mockChannelData = { frozen: true }
69
+
70
+ renderWithProviders(<CustomMessageInput />)
71
+
72
+ const textarea = screen.getByTestId('textarea-composer')
73
+ expect(textarea).toHaveAttribute('aria-disabled', 'true')
74
+ expect(textarea).toHaveAttribute('readonly')
75
+ expect(textarea).toHaveAttribute('tabindex', '-1')
76
+ expect(textarea).not.toHaveAttribute('autofocus')
77
+
78
+ const sendButton = screen.getByRole('button', { name: /send/i })
79
+ expect(sendButton).toBeDisabled()
80
+ })
81
+
82
+ it('renders the existing attachment selector when channel is frozen', () => {
83
+ mockChannelData = { frozen: true }
84
+
85
+ renderWithProviders(<CustomMessageInput />)
86
+
87
+ expect(screen.getByTestId('simple-attachment-selector')).toBeInTheDocument()
88
+ })
89
+
90
+ it('renders adjacent actions inside the disabled container when frozen', () => {
91
+ mockChannelData = { frozen: true }
92
+
93
+ const { container } = renderWithProviders(
94
+ <CustomMessageInput
95
+ renderActions={() => (
96
+ <button data-testid="custom-action" type="button">
97
+ Action
98
+ </button>
99
+ )}
100
+ />
101
+ )
102
+
103
+ const messageInput = container.firstElementChild
104
+ const action = screen.getByTestId('custom-action')
105
+
106
+ expect(messageInput).toHaveAttribute('aria-disabled', 'true')
107
+ expect(messageInput?.contains(action)).toBe(true)
108
+ })
109
+
110
+ it('renders adjacent actions in the interactive container when not frozen', () => {
111
+ mockChannelData = {}
112
+
113
+ const { container } = renderWithProviders(
114
+ <CustomMessageInput
115
+ renderActions={() => (
116
+ <button data-testid="custom-action" type="button">
117
+ Action
118
+ </button>
119
+ )}
120
+ />
121
+ )
122
+
123
+ const messageInput = container.firstElementChild
124
+ expect(messageInput).not.toHaveAttribute('aria-disabled')
125
+ expect(screen.getByTestId('custom-action')).toBeInTheDocument()
126
+ })
127
+ })
@@ -6,6 +6,7 @@ import {
6
6
  QuotedMessagePreview,
7
7
  SimpleAttachmentSelector,
8
8
  TextareaComposer,
9
+ useChannelStateContext,
9
10
  useMessageComposerHasSendableData,
10
11
  useMessageInputContext,
11
12
  } from 'stream-chat-react'
@@ -13,8 +14,11 @@ import {
13
14
  import { CustomLinkPreviewList } from '../CustomLinkPreviewList'
14
15
 
15
16
  const CustomMessageInputInner: React.FC = () => {
17
+ const { channel } = useChannelStateContext()
18
+ const isFrozen = channel?.data?.frozen === true
16
19
  const { handleSubmit } = useMessageInputContext()
17
20
  const hasSendableData = useMessageComposerHasSendableData()
21
+ const isSendDisabled = isFrozen || !hasSendableData
18
22
 
19
23
  return (
20
24
  <>
@@ -28,20 +32,23 @@ const CustomMessageInputInner: React.FC = () => {
28
32
  <div className="flex">
29
33
  <div className="w-full ml-2 mr-4 self-center leading-[0]">
30
34
  <TextareaComposer
35
+ aria-disabled={isFrozen || undefined}
31
36
  className="w-full resize-none outline-none leading-6"
32
37
  // While this might usually be considered an anti-pattern, in most
33
38
  // cases, when a message thread is rendered, we want the input to
34
39
  // gain focus automatically.
35
40
  // eslint-disable-next-line jsx-a11y/no-autofocus
36
- autoFocus
41
+ autoFocus={!isFrozen}
37
42
  maxRows={4}
43
+ readOnly={isFrozen}
44
+ tabIndex={isFrozen ? -1 : undefined}
38
45
  />
39
46
  </div>
40
47
  <button
41
48
  aria-label="Send"
42
49
  className="str-chat__send-button mt-auto flex justify-center items-center flex-shrink-0 rounded-full size-8 bg-[#121110] disabled:bg-[#F1F0EE] disabled:text-black/20 text-white focus-ring"
43
50
  data-testid="send-button"
44
- disabled={!hasSendableData}
51
+ disabled={isSendDisabled}
45
52
  onClick={handleSubmit}
46
53
  type="button"
47
54
  >
@@ -59,9 +66,19 @@ export interface CustomMessageInputProps {
59
66
 
60
67
  export const CustomMessageInput: React.FC<CustomMessageInputProps> = ({
61
68
  renderActions,
62
- }) => (
63
- <div className="message-input flex items-center gap-2 p-4">
64
- {renderActions && renderActions?.()}
65
- <MessageInput Input={CustomMessageInputInner} />
66
- </div>
67
- )
69
+ }) => {
70
+ const { channel } = useChannelStateContext()
71
+ const isFrozen = channel?.data?.frozen === true
72
+
73
+ return (
74
+ <div
75
+ // @ts-expect-error Only React 19 onwards has `inert` in its types.
76
+ inert={isFrozen ? '' : undefined}
77
+ aria-disabled={isFrozen || undefined}
78
+ className="message-input flex items-center gap-2 p-4 aria-disabled:opacity-40"
79
+ >
80
+ {renderActions?.()}
81
+ <MessageInput Input={CustomMessageInputInner} />
82
+ </div>
83
+ )
84
+ }