@linktr.ee/messaging-react 3.3.4 → 3.3.6-rc-1780987607

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 (56) hide show
  1. package/dist/{Card-DLmUSU4A.cjs → Card-BlviN8Fb.cjs} +2 -2
  2. package/dist/{Card-DLmUSU4A.cjs.map → Card-BlviN8Fb.cjs.map} +1 -1
  3. package/dist/{Card-DmPpcrSU.js → Card-C4ncqjxJ.js} +2 -2
  4. package/dist/{Card-DmPpcrSU.js.map → Card-C4ncqjxJ.js.map} +1 -1
  5. package/dist/{Card-0BgubwgM.cjs → Card-Cn7Zxc6U.cjs} +2 -2
  6. package/dist/{Card-0BgubwgM.cjs.map → Card-Cn7Zxc6U.cjs.map} +1 -1
  7. package/dist/{Card-DchJqvYq.js → Card-DE5bfj0l.js} +2 -2
  8. package/dist/{Card-DchJqvYq.js.map → Card-DE5bfj0l.js.map} +1 -1
  9. package/dist/{Card-B7AF5uOB.js → Card-IjOI7UXs.js} +3 -3
  10. package/dist/{Card-B7AF5uOB.js.map → Card-IjOI7UXs.js.map} +1 -1
  11. package/dist/{Card-CvBbAoUo.cjs → Card-KgQxeR-B.cjs} +2 -2
  12. package/dist/{Card-CvBbAoUo.cjs.map → Card-KgQxeR-B.cjs.map} +1 -1
  13. package/dist/{LockedThumbnail-BQjA4HaB.js → LockedThumbnail-4-54cyJG.js} +2 -2
  14. package/dist/{LockedThumbnail-BQjA4HaB.js.map → LockedThumbnail-4-54cyJG.js.map} +1 -1
  15. package/dist/{LockedThumbnail-D9fSb4N-.cjs → LockedThumbnail-DL5NZzWJ.cjs} +2 -2
  16. package/dist/{LockedThumbnail-D9fSb4N-.cjs.map → LockedThumbnail-DL5NZzWJ.cjs.map} +1 -1
  17. package/dist/{index-BcHUpyyw.js → index-C2wfgpUU.js} +855 -823
  18. package/dist/index-C2wfgpUU.js.map +1 -0
  19. package/dist/index-nanry0Io.cjs +2 -0
  20. package/dist/index-nanry0Io.cjs.map +1 -0
  21. package/dist/index.cjs +1 -1
  22. package/dist/index.js +1 -1
  23. package/package.json +5 -2
  24. package/src/components/ActionButton/ActionButton.test.tsx +0 -25
  25. package/src/components/AttachmentCard/AttachmentCard.stories.tsx +226 -0
  26. package/src/components/Avatar/Avatar.stories.tsx +20 -0
  27. package/src/components/ChannelActionsMenu/ChannelActionsMenu.test.tsx +33 -8
  28. package/src/components/ChannelList/ChannelList.stories.tsx +5 -0
  29. package/src/components/ChannelList/CustomChannelPreview.stories.tsx +77 -47
  30. package/src/components/ChannelView.stories.tsx +8 -7
  31. package/src/components/ChannelView.test.tsx +12 -1
  32. package/src/components/ChannelView.tsx +34 -17
  33. package/src/components/CloseButton/CloseButton.stories.tsx +31 -0
  34. package/src/components/CustomDateSeparator/CustomDateSeparator.stories.tsx +33 -0
  35. package/src/components/CustomLinkPreviewList/CustomLinkPreviewCard.stories.tsx +63 -0
  36. package/src/components/CustomLinkPreviewList/CustomLinkPreviewCard.tsx +57 -0
  37. package/src/components/CustomLinkPreviewList/index.tsx +2 -54
  38. package/src/components/CustomMessage/CustomMessage.stories.tsx +3 -18
  39. package/src/components/CustomMessage/MessageAttachmentConversations.stories.tsx +13 -0
  40. package/src/components/CustomMessage/MessageTag.stories.tsx +22 -2
  41. package/src/components/CustomMessage/MessageVoteButtons.stories.tsx +9 -0
  42. package/src/components/CustomMessageInput/index.tsx +14 -4
  43. package/src/components/CustomSystemMessage/CustomSystemMessage.stories.tsx +54 -0
  44. package/src/components/CustomTypingIndicator/CustomTypingIndicator.stories.tsx +7 -0
  45. package/src/components/LinkAttachment/LinkAttachment.stories.tsx +11 -1
  46. package/src/components/LockedAttachment/LockedAttachment.stories.tsx +4 -0
  47. package/src/components/MediaMessage/MediaMessage.stories.tsx +4 -2
  48. package/src/components/MediaMessage/MediaMessage.test.tsx +0 -38
  49. package/src/components/MessageAttachment/MessageAttachment.test.tsx +25 -84
  50. package/src/components/SearchInput/SearchInput.test.tsx +0 -8
  51. package/src/hooks/useChannelModerationActions.ts +32 -14
  52. package/src/stories/decorators/storyTime.ts +31 -0
  53. package/src/utils/formatRelativeTime.test.ts +1 -32
  54. package/dist/index-BcHUpyyw.js.map +0 -1
  55. package/dist/index-DTZNltUC.cjs +0 -2
  56. package/dist/index-DTZNltUC.cjs.map +0 -1
package/dist/index.cjs CHANGED
@@ -1,2 +1,2 @@
1
- "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("./index-DTZNltUC.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.resolveLinkAttachment=e.resolveLinkAttachment;exports.resolveMediaFromMessage=e.resolveMediaFromMessage;exports.resolveParticipantDisplayName=e.resolveParticipantDisplayName;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-nanry0Io.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.resolveLinkAttachment=e.resolveLinkAttachment;exports.resolveMediaFromMessage=e.resolveMediaFromMessage;exports.resolveParticipantDisplayName=e.resolveParticipantDisplayName;exports.useCustomMessage=e.useCustomMessage;exports.useMessageVote=e.useMessageVote;exports.useMessaging=e.useMessaging;
2
2
  //# sourceMappingURL=index.cjs.map
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as e, b as t, C as i, c as n, d as o, e as m, F as g, f as l, L as r, h as M, M as u, i as L, j as c, k as h, l as d, m as p, n as v, o as A, p as C, q as F, s as k, t as b, u as f, v as x, w as y, x as P, y as S, z as q, B as z, D as B } from "./index-BcHUpyyw.js";
1
+ import { a as e, b as t, C as i, c as n, d as o, e as m, F as g, f as l, L as r, h as M, M as u, i as L, j as c, k as h, l as d, m as p, n as v, o as A, p as C, q as F, s as k, t as b, u as f, v as x, w as y, x as P, y as S, z as q, B as z, D as B } from "./index-C2wfgpUU.js";
2
2
  export {
3
3
  e as ActionButton,
4
4
  t as Avatar,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linktr.ee/messaging-react",
3
- "version": "3.3.4",
3
+ "version": "3.3.6-rc-1780987607",
4
4
  "description": "React messaging components built on messaging-core for web applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -32,7 +32,9 @@
32
32
  "lint:fix": "npx --no-install eslint . --ext .ts,.tsx --fix",
33
33
  "verify": "yarn type-check && yarn lint && yarn test:ci && yarn build",
34
34
  "storybook": "storybook dev -p 6006",
35
- "storybook:build": "storybook build"
35
+ "storybook:build": "storybook build --stats-json",
36
+ "chromatic:ci:feature": "chromatic --ci --exit-zero-on-changes --exit-once-uploaded",
37
+ "chromatic:ci:main": "chromatic --ci --auto-accept-changes"
36
38
  },
37
39
  "dependencies": {
38
40
  "@linktr.ee/component-library": "11.8.6",
@@ -56,6 +58,7 @@
56
58
  "@vitejs/plugin-react": "^4.2.1",
57
59
  "@vitest/ui": "^1.0.4",
58
60
  "autoprefixer": "^10.4.20",
61
+ "chromatic": "^11.20.0",
59
62
  "classnames": "^2.3.2",
60
63
  "jsdom": "^23.0.1",
61
64
  "postcss": "^8.4.49",
@@ -10,24 +10,6 @@ describe('ActionButton', () => {
10
10
  renderWithProviders(<ActionButton>Click me</ActionButton>);
11
11
  expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
12
12
  });
13
-
14
- it('renders with default variant', () => {
15
- renderWithProviders(<ActionButton>Default</ActionButton>);
16
- const button = screen.getByRole('button');
17
- expect(button).toHaveClass('text-charcoal');
18
- });
19
-
20
- it('renders with danger variant', () => {
21
- renderWithProviders(<ActionButton variant="danger">Delete</ActionButton>);
22
- const button = screen.getByRole('button');
23
- expect(button).toHaveClass('text-danger');
24
- });
25
-
26
- it('has full width by default', () => {
27
- renderWithProviders(<ActionButton>Full Width</ActionButton>);
28
- const button = screen.getByRole('button');
29
- expect(button).toHaveClass('w-full');
30
- });
31
13
  });
32
14
 
33
15
  describe('Disabled State', () => {
@@ -52,13 +34,6 @@ describe('ActionButton', () => {
52
34
 
53
35
  expect(handleClick).not.toHaveBeenCalled();
54
36
  });
55
-
56
- it('applies disabled styles', () => {
57
- renderWithProviders(<ActionButton disabled>Disabled</ActionButton>);
58
- const button = screen.getByRole('button');
59
- expect(button).toHaveClass('disabled:opacity-60');
60
- expect(button).toHaveClass('disabled:cursor-not-allowed');
61
- });
62
37
  });
63
38
 
64
39
  describe('Click Handling', () => {
@@ -0,0 +1,226 @@
1
+ import type { Meta, StoryFn } from '@storybook/react'
2
+ import React from 'react'
3
+
4
+ import AttachmentCard, { AttachmentThumbnail } from '.'
5
+
6
+ type ComponentProps = React.ComponentProps<typeof AttachmentCard>
7
+
8
+ const meta: Meta<ComponentProps> = {
9
+ title: 'AttachmentCard',
10
+ component: AttachmentCard,
11
+ parameters: { layout: 'centered' },
12
+ argTypes: {
13
+ variant: { control: { type: 'inline-radio' }, options: ['light', 'dark'] },
14
+ },
15
+ }
16
+ export default meta
17
+
18
+ const IMAGE_URL = '/image-thumbnail.jpg'
19
+
20
+ const Template: StoryFn<ComponentProps> = (args) => <AttachmentCard {...args} />
21
+
22
+ // Per-variant stories stay in Storybook for browsing/design review but are
23
+ // individually covered by cells in AllVariants — skip in Chromatic.
24
+ const skipInChromatic = { chromatic: { disableSnapshot: true } }
25
+
26
+ export const LightImage: StoryFn<ComponentProps> = Template.bind({})
27
+ LightImage.args = {
28
+ variant: 'light',
29
+ title: 'sunset.jpg',
30
+ mimeType: 'image/jpeg',
31
+ detail: '2.4 MB',
32
+ thumbnail: (
33
+ <AttachmentThumbnail
34
+ mimeType="image/jpeg"
35
+ sourceUrl={IMAGE_URL}
36
+ title="sunset"
37
+ variant="light"
38
+ />
39
+ ),
40
+ }
41
+ LightImage.parameters = skipInChromatic
42
+
43
+ export const DarkImage: StoryFn<ComponentProps> = Template.bind({})
44
+ DarkImage.args = {
45
+ variant: 'dark',
46
+ title: 'sunset.jpg',
47
+ mimeType: 'image/jpeg',
48
+ detail: '2.4 MB',
49
+ thumbnail: (
50
+ <AttachmentThumbnail
51
+ mimeType="image/jpeg"
52
+ sourceUrl={IMAGE_URL}
53
+ title="sunset"
54
+ variant="dark"
55
+ />
56
+ ),
57
+ }
58
+ DarkImage.parameters = skipInChromatic
59
+
60
+ export const PdfPlaceholder: StoryFn<ComponentProps> = Template.bind({})
61
+ PdfPlaceholder.args = {
62
+ variant: 'light',
63
+ title: 'invoice.pdf',
64
+ mimeType: 'application/pdf',
65
+ detail: '120 KB',
66
+ thumbnail: <AttachmentThumbnail mimeType="application/pdf" variant="light" />,
67
+ }
68
+ PdfPlaceholder.parameters = skipInChromatic
69
+
70
+ export const AudioPosterOnly: StoryFn<ComponentProps> = Template.bind({})
71
+ AudioPosterOnly.args = {
72
+ variant: 'dark',
73
+ title: 'voice-memo.m4a',
74
+ mimeType: 'audio/m4a',
75
+ detail: '0:42',
76
+ thumbnail: (
77
+ <AttachmentThumbnail
78
+ mimeType="audio/m4a"
79
+ thumbnailUrl={IMAGE_URL}
80
+ title="voice memo"
81
+ variant="dark"
82
+ />
83
+ ),
84
+ }
85
+ AudioPosterOnly.parameters = skipInChromatic
86
+
87
+ export const UntitledDark: StoryFn<ComponentProps> = Template.bind({})
88
+ UntitledDark.args = {
89
+ variant: 'dark',
90
+ mimeType: 'image/png',
91
+ detail: '1.1 MB',
92
+ thumbnail: <AttachmentThumbnail mimeType="image/png" variant="dark" />,
93
+ }
94
+ UntitledDark.parameters = skipInChromatic
95
+
96
+ export const WithTopBadges: StoryFn<ComponentProps> = Template.bind({})
97
+ WithTopBadges.args = {
98
+ variant: 'light',
99
+ title: 'receipt.pdf',
100
+ mimeType: 'application/pdf',
101
+ detail: 'Paid',
102
+ thumbnail: <AttachmentThumbnail mimeType="application/pdf" variant="light" />,
103
+ topLeft: (
104
+ <span className="rounded-full bg-black/80 px-2 py-0.5 text-xs font-medium text-white">
105
+ Locked
106
+ </span>
107
+ ),
108
+ topRight: (
109
+ <span className="rounded-full bg-white/90 px-2 py-0.5 text-xs font-medium text-black">
110
+ $5.00
111
+ </span>
112
+ ),
113
+ }
114
+ WithTopBadges.parameters = skipInChromatic
115
+
116
+ export const AllVariants: StoryFn = () => {
117
+ const cards: { label: string; props: ComponentProps }[] = [
118
+ {
119
+ label: 'Light image',
120
+ props: {
121
+ variant: 'light',
122
+ title: 'sunset.jpg',
123
+ mimeType: 'image/jpeg',
124
+ detail: '2.4 MB',
125
+ thumbnail: (
126
+ <AttachmentThumbnail
127
+ mimeType="image/jpeg"
128
+ sourceUrl={IMAGE_URL}
129
+ title="sunset"
130
+ variant="light"
131
+ />
132
+ ),
133
+ },
134
+ },
135
+ {
136
+ label: 'Dark image',
137
+ props: {
138
+ variant: 'dark',
139
+ title: 'sunset.jpg',
140
+ mimeType: 'image/jpeg',
141
+ detail: '2.4 MB',
142
+ thumbnail: (
143
+ <AttachmentThumbnail
144
+ mimeType="image/jpeg"
145
+ sourceUrl={IMAGE_URL}
146
+ title="sunset"
147
+ variant="dark"
148
+ />
149
+ ),
150
+ },
151
+ },
152
+ {
153
+ label: 'PDF placeholder',
154
+ props: {
155
+ variant: 'light',
156
+ title: 'invoice.pdf',
157
+ mimeType: 'application/pdf',
158
+ detail: '120 KB',
159
+ thumbnail: (
160
+ <AttachmentThumbnail mimeType="application/pdf" variant="light" />
161
+ ),
162
+ },
163
+ },
164
+ {
165
+ label: 'Audio poster',
166
+ props: {
167
+ variant: 'dark',
168
+ title: 'voice-memo.m4a',
169
+ mimeType: 'audio/m4a',
170
+ detail: '0:42',
171
+ thumbnail: (
172
+ <AttachmentThumbnail
173
+ mimeType="audio/m4a"
174
+ thumbnailUrl={IMAGE_URL}
175
+ title="voice memo"
176
+ variant="dark"
177
+ />
178
+ ),
179
+ },
180
+ },
181
+ {
182
+ label: 'Untitled (dark)',
183
+ props: {
184
+ variant: 'dark',
185
+ mimeType: 'image/png',
186
+ detail: '1.1 MB',
187
+ thumbnail: (
188
+ <AttachmentThumbnail mimeType="image/png" variant="dark" />
189
+ ),
190
+ },
191
+ },
192
+ {
193
+ label: 'With top badges',
194
+ props: {
195
+ variant: 'light',
196
+ title: 'receipt.pdf',
197
+ mimeType: 'application/pdf',
198
+ detail: 'Paid',
199
+ thumbnail: (
200
+ <AttachmentThumbnail mimeType="application/pdf" variant="light" />
201
+ ),
202
+ topLeft: (
203
+ <span className="rounded-full bg-black/80 px-2 py-0.5 text-xs font-medium text-white">
204
+ Locked
205
+ </span>
206
+ ),
207
+ topRight: (
208
+ <span className="rounded-full bg-white/90 px-2 py-0.5 text-xs font-medium text-black">
209
+ $5.00
210
+ </span>
211
+ ),
212
+ },
213
+ },
214
+ ]
215
+
216
+ return (
217
+ <div className="grid grid-cols-2 gap-6 p-6">
218
+ {cards.map(({ label, props }) => (
219
+ <div key={label} className="flex flex-col gap-2">
220
+ <span className="text-xs text-stone">{label}</span>
221
+ <AttachmentCard {...props} />
222
+ </div>
223
+ ))}
224
+ </div>
225
+ )
226
+ }
@@ -29,6 +29,11 @@ Default.args = {
29
29
  image: 'https://i.pravatar.cc/150?img=1',
30
30
  }
31
31
 
32
+ // Per-size stories (SmallSize…ExtraLargeSize) are each a single cell of the
33
+ // VariousSizes grid; AIActive is the size=40 cell of AIActiveVariousSizes.
34
+ // Keep them in Storybook for browsing but skip in Chromatic.
35
+ const skipInChromatic = { chromatic: { disableSnapshot: true } }
36
+
32
37
  export const AIActive: StoryFn<ComponentProps> = Template.bind({})
33
38
  AIActive.args = {
34
39
  id: 'ai-agent',
@@ -36,6 +41,7 @@ AIActive.args = {
36
41
  image: 'https://i.pravatar.cc/150?img=12',
37
42
  dmAgentEnabled: true,
38
43
  }
44
+ AIActive.parameters = skipInChromatic
39
45
 
40
46
  export const WithoutImage: StoryFn<ComponentProps> = Template.bind({})
41
47
  WithoutImage.args = {
@@ -56,6 +62,7 @@ SmallSize.args = {
56
62
  name: 'Carol Williams',
57
63
  size: 20,
58
64
  }
65
+ SmallSize.parameters = skipInChromatic
59
66
 
60
67
  export const MediumSize: StoryFn<ComponentProps> = Template.bind({})
61
68
  MediumSize.args = {
@@ -63,6 +70,7 @@ MediumSize.args = {
63
70
  name: 'David Brown',
64
71
  size: 32,
65
72
  }
73
+ MediumSize.parameters = skipInChromatic
66
74
 
67
75
  export const DefaultSize: StoryFn<ComponentProps> = Template.bind({})
68
76
  DefaultSize.args = {
@@ -70,6 +78,7 @@ DefaultSize.args = {
70
78
  name: 'Emma Davis',
71
79
  size: 40,
72
80
  }
81
+ DefaultSize.parameters = skipInChromatic
73
82
 
74
83
  export const LargeSize: StoryFn<ComponentProps> = Template.bind({})
75
84
  LargeSize.args = {
@@ -77,6 +86,7 @@ LargeSize.args = {
77
86
  name: 'Frank Miller',
78
87
  size: 56,
79
88
  }
89
+ LargeSize.parameters = skipInChromatic
80
90
 
81
91
  export const ExtraLargeSize: StoryFn<ComponentProps> = Template.bind({})
82
92
  ExtraLargeSize.args = {
@@ -84,12 +94,17 @@ ExtraLargeSize.args = {
84
94
  name: 'Grace Lee',
85
95
  size: 80,
86
96
  }
97
+ ExtraLargeSize.parameters = skipInChromatic
87
98
 
99
+ // Avatars don't render the participant name in the bubble — only the initial.
100
+ // LongName stays as a Storybook fixture for documentation but adds no visual
101
+ // regression value beyond what Default + WithoutImage already cover.
88
102
  export const LongName: StoryFn<ComponentProps> = Template.bind({})
89
103
  LongName.args = {
90
104
  id: 'user-8',
91
105
  name: 'Alexander Christopher Wellington-Montgomery III',
92
106
  }
107
+ LongName.parameters = { chromatic: { disableSnapshot: true } }
93
108
 
94
109
  export const DifferentColors: StoryFn = () => {
95
110
  const users = [
@@ -117,6 +132,10 @@ export const DifferentColors: StoryFn = () => {
117
132
  )
118
133
  }
119
134
 
135
+ // Showcase grid that crosses image-present/absent with different users.
136
+ // Each individual case is already covered by `Default` (image) and
137
+ // `WithoutImage` (initial) — skip the showcase in Chromatic so a single
138
+ // pravatar avatar flake doesn't diff the whole grid.
120
139
  export const MixedAvatars: StoryFn = () => {
121
140
  const users = [
122
141
  { id: 'user-1', name: 'Alice Anderson', image: 'https://i.pravatar.cc/150?img=1' },
@@ -140,6 +159,7 @@ export const MixedAvatars: StoryFn = () => {
140
159
  </div>
141
160
  )
142
161
  }
162
+ MixedAvatars.parameters = { chromatic: { disableSnapshot: true } }
143
163
 
144
164
  export const VariousSizes: StoryFn = () => {
145
165
  const sizes = [20, 32, 40, 56, 80]
@@ -19,15 +19,19 @@ const { getBlockedUsersMock, blockUserMock, unBlockUserMock } = vi.hoisted(
19
19
  })
20
20
  )
21
21
 
22
+ // Stable service + context references — production MessagingProvider keeps
23
+ // the service in useState and memoizes the context value, so the hook's
24
+ // service-keyed lookup invalidation only fires on real swaps. Returning a
25
+ // new object literal each call would re-trigger the lookup every render and
26
+ // loop forever.
27
+ const mockService = {
28
+ getBlockedUsers: getBlockedUsersMock,
29
+ blockUser: blockUserMock,
30
+ unBlockUser: unBlockUserMock,
31
+ }
32
+ const mockContext = { service: mockService, debug: false }
22
33
  vi.mock('../../providers/MessagingProvider', () => ({
23
- useMessagingContext: () => ({
24
- service: {
25
- getBlockedUsers: getBlockedUsersMock,
26
- blockUser: blockUserMock,
27
- unBlockUser: unBlockUserMock,
28
- },
29
- debug: false,
30
- }),
34
+ useMessagingContext: () => mockContext,
31
35
  }))
32
36
 
33
37
  vi.mock('../ActionButton', () => ({
@@ -229,6 +233,27 @@ describe('ChannelActionsMenu', () => {
229
233
  })
230
234
  })
231
235
 
236
+ it('recovers from a blocked-status lookup failure', async () => {
237
+ // Suppress the expected console.error for the rejected lookup so the
238
+ // test output stays clean.
239
+ const consoleErrorSpy = vi
240
+ .spyOn(console, 'error')
241
+ .mockImplementation(() => {})
242
+ getBlockedUsersMock.mockRejectedValueOnce(new Error('network down'))
243
+
244
+ renderWithProviders(<ChannelActionsMenu {...defaultProps()} />)
245
+ await openMenu()
246
+
247
+ // The lookup rejected, but the action recovers — Block becomes actionable
248
+ // rather than staying stuck in the disabled spinner state.
249
+ await waitFor(() => {
250
+ const block = screen.getByText('Block').closest('button')
251
+ expect(block).not.toBeDisabled()
252
+ })
253
+
254
+ consoleErrorSpy.mockRestore()
255
+ })
256
+
232
257
  it('calls onReportParticipantClick and opens the report page', async () => {
233
258
  const onReportParticipantClick = vi.fn()
234
259
  const windowOpenSpy = vi
@@ -8,11 +8,16 @@ import { ChannelList } from '.'
8
8
 
9
9
  type ComponentProps = React.ComponentProps<typeof ChannelList>
10
10
 
11
+ // ChannelList renders a list of mocked channels driven by MockChatProvider —
12
+ // useful as a browsable Storybook fixture but the snapshot reflects the mock
13
+ // fixture rather than any reusable component contract. Skip the whole story
14
+ // file in Chromatic; per-channel-row visuals are covered by CustomChannelPreview.
11
15
  const meta: Meta<ComponentProps> = {
12
16
  title: 'ChannelList',
13
17
  component: ChannelList,
14
18
  parameters: {
15
19
  layout: 'fullscreen',
20
+ chromatic: { disableSnapshot: true },
16
21
  },
17
22
  decorators: [
18
23
  (Story) => (