@linktr.ee/messaging-react 4.2.0-rc-1787325521 → 4.2.0-rc-1787599724

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 (96) hide show
  1. package/dist/{Card-BYJyA83X.js → Card-D7UPq5-g.js} +2 -2
  2. package/dist/{Card-BYJyA83X.js.map → Card-D7UPq5-g.js.map} +1 -1
  3. package/dist/{Card-BplJtZao.cjs → Card-DcBZqMBt.cjs} +2 -2
  4. package/dist/{Card-BplJtZao.cjs.map → Card-DcBZqMBt.cjs.map} +1 -1
  5. package/dist/assets/index.css +1 -1
  6. package/dist/{index-BUC9vqhJ.js → index-CSkr_CO5.js} +2089 -1952
  7. package/dist/index-CSkr_CO5.js.map +1 -0
  8. package/dist/index-CwkY7QwT.cjs +3 -0
  9. package/dist/index-CwkY7QwT.cjs.map +1 -0
  10. package/dist/index.cjs +1 -1
  11. package/dist/index.d.ts +59 -78
  12. package/dist/index.js +26 -30
  13. package/dist/testing.cjs +1 -1
  14. package/dist/testing.cjs.map +1 -1
  15. package/dist/testing.js +2 -2
  16. package/dist/testing.js.map +1 -1
  17. package/package.json +13 -11
  18. package/src/components/AttachmentCard/utils/icons.ts +7 -6
  19. package/src/components/Avatar/Avatar.test.tsx +0 -126
  20. package/src/components/Avatar/getAvatarEmoji.test.ts +0 -39
  21. package/src/components/ChannelList/CustomChannelPreview.test.tsx +3 -410
  22. package/src/components/ChannelList/index.test.tsx +1 -132
  23. package/src/components/ChannelView.stories.tsx +0 -4
  24. package/src/components/ChannelView.test.tsx +3 -9
  25. package/src/components/CustomMessage/CustomMessage.stories.tsx +314 -6
  26. package/src/components/CustomMessage/CustomMessage.test.tsx +2 -56
  27. package/src/components/CustomMessage/MessageTag.stories.tsx +53 -17
  28. package/src/components/CustomMessage/MessageTag.test.tsx +40 -7
  29. package/src/components/CustomMessage/MessageTag.tsx +39 -7
  30. package/src/components/CustomMessage/SentMessageDeliveryStatus.test.tsx +7 -8
  31. package/src/components/CustomMessage/SentMessageDeliveryStatus.tsx +1 -2
  32. package/src/components/CustomMessage/StreamAttachmentMessage.test.tsx +33 -0
  33. package/src/components/CustomMessage/StreamAttachmentMessage.tsx +12 -5
  34. package/src/components/CustomMessage/index.tsx +19 -22
  35. package/src/components/CustomMessageInput/index.tsx +1 -1
  36. package/src/components/CustomSystemMessage/index.tsx +2 -3
  37. package/src/components/LinkAttachment/LinkAttachment.test.tsx +0 -327
  38. package/src/components/MessageAttachment/Audio/AudioAttachment.stories.tsx +165 -58
  39. package/src/components/MessageAttachment/Audio/WaveformAudioRow.test.tsx +153 -0
  40. package/src/components/MessageAttachment/Audio/WaveformAudioRow.tsx +450 -0
  41. package/src/components/MessageAttachment/Audio/audioRowRequester.test.ts +24 -0
  42. package/src/components/MessageAttachment/Audio/audioRowRequester.ts +16 -0
  43. package/src/components/MessageAttachment/Audio/index.tsx +83 -77
  44. package/src/components/MessageAttachment/MessageAttachment.test.tsx +176 -74
  45. package/src/components/MessageAttachment/_shared/fileMeta.test.ts +0 -44
  46. package/src/components/MessageAttachment/_shared/useIsClient.ts +23 -0
  47. package/src/components/MessageAttachment/index.tsx +25 -9
  48. package/src/components/MessageAttachment/types.ts +19 -9
  49. package/src/components/MessagingShell/MessagingShell.test.tsx +0 -20
  50. package/src/components/MessagingShell/index.tsx +0 -2
  51. package/src/components/RichCard/RichCard.test.tsx +1 -3
  52. package/src/index.ts +0 -4
  53. package/src/providers/MessagingProvider.test.tsx +2 -130
  54. package/src/providers/MessagingProvider.tsx +15 -26
  55. package/src/styles.css +210 -57
  56. package/src/testing/createMockMessagingClient.ts +2 -2
  57. package/src/types.ts +0 -9
  58. package/dist/index-BUC9vqhJ.js.map +0 -1
  59. package/dist/index-BkqyKPJP.cjs +0 -3
  60. package/dist/index-BkqyKPJP.cjs.map +0 -1
  61. package/src/components/AttachmentCard/MediaPlayer.test.tsx +0 -346
  62. package/src/components/AttachmentCard/Thumbnail.test.tsx +0 -215
  63. package/src/components/AttachmentCard/index.test.tsx +0 -160
  64. package/src/components/ChannelList/ChannelListContext.test.tsx +0 -87
  65. package/src/components/CustomMessage/Locked.stories.tsx +0 -108
  66. package/src/components/CustomMessage/PaidMessage.stories.tsx +0 -42
  67. package/src/components/CustomMessage/Tip.stories.tsx +0 -48
  68. package/src/components/CustomMessage/TipMessage/TipMessage.stories.tsx +0 -51
  69. package/src/components/CustomMessage/TipMessage/TipMessage.test.tsx +0 -65
  70. package/src/components/CustomMessage/TipMessage/index.tsx +0 -27
  71. package/src/components/FaqList/FaqList.test.tsx +0 -204
  72. package/src/components/LinkAttachment/components/_shared/BubbleTail.test.tsx +0 -76
  73. package/src/components/LinkAttachment/components/_shared/CardBody.test.tsx +0 -248
  74. package/src/components/LinkAttachment/components/_shared/CardCta.test.tsx +0 -123
  75. package/src/components/LinkAttachment/components/_shared/CardShell.test.tsx +0 -193
  76. package/src/components/LinkAttachment/components/_shared/CardThumbnail.test.tsx +0 -242
  77. package/src/components/LinkAttachment/components/_shared/hex.test.ts +0 -37
  78. package/src/components/LinkAttachment/components/_shared/normalizeExternalHref.test.ts +0 -72
  79. package/src/components/LinkAttachment/components/_shared/useChinPalette.test.ts +0 -162
  80. package/src/components/MessageAttachment/MessageAttachment.behavior.test.tsx +0 -182
  81. package/src/components/MessageAttachment/_shared/Bubble.test.tsx +0 -111
  82. package/src/components/MessageAttachment/_shared/CarouselNav.test.tsx +0 -36
  83. package/src/components/MessageAttachment/_shared/CompactDocumentRow.test.tsx +0 -114
  84. package/src/components/MessageAttachment/_shared/DismissButton.test.tsx +0 -42
  85. package/src/components/MessageAttachment/_shared/DownloadAction.test.tsx +0 -168
  86. package/src/components/MessageAttachment/_shared/ImageViewer.test.tsx +0 -52
  87. package/src/components/MessageAttachment/_shared/MediaStackGrid.test.tsx +0 -69
  88. package/src/components/MessageAttachment/_shared/PdfViewer.test.tsx +0 -45
  89. package/src/components/MessageAttachment/_shared/VideoViewer.test.tsx +0 -55
  90. package/src/components/MessageAttachment/_shared/ViewerShell.test.tsx +0 -246
  91. package/src/components/MessageAttachment/_shared/triggerDownload.test.ts +0 -113
  92. package/src/components/MessageAttachment/_shared/useCarousel.test.tsx +0 -68
  93. package/src/components/MessageAttachment/_shared/viewerChromeClasses.test.ts +0 -16
  94. package/src/logging/index.test.tsx +0 -136
  95. package/src/logging/index.tsx +0 -48
  96. package/src/stories/customMessageStoryTemplate.tsx +0 -202
@@ -1,113 +0,0 @@
1
- import { beforeEach, describe, expect, it, vi } from 'vitest'
2
-
3
- import { triggerDownload } from './triggerDownload'
4
-
5
- describe('triggerDownload', () => {
6
- const fetchMock = vi.fn()
7
- const openMock = vi.fn()
8
- const createObjectURLMock = vi.fn()
9
- const revokeObjectURLMock = vi.fn()
10
- const clickMock = vi
11
- .spyOn(HTMLAnchorElement.prototype, 'click')
12
- .mockImplementation(() => undefined)
13
-
14
- beforeEach(() => {
15
- vi.stubGlobal('fetch', fetchMock)
16
- vi.spyOn(window, 'open').mockImplementation(openMock)
17
- Object.defineProperty(URL, 'createObjectURL', {
18
- configurable: true,
19
- value: createObjectURLMock,
20
- })
21
- Object.defineProperty(URL, 'revokeObjectURL', {
22
- configurable: true,
23
- value: revokeObjectURLMock,
24
- })
25
- fetchMock.mockReset()
26
- openMock.mockReset()
27
- createObjectURLMock.mockReset()
28
- revokeObjectURLMock.mockReset()
29
- clickMock.mockClear()
30
- })
31
-
32
- it('downloads a successful response with the supplied filename', async () => {
33
- const blob = new Blob(['file'])
34
- fetchMock.mockResolvedValue({
35
- ok: true,
36
- blob: () => Promise.resolve(blob),
37
- })
38
- createObjectURLMock.mockReturnValue('blob:attachment')
39
- const appendSpy = vi.spyOn(document.body, 'appendChild')
40
- const removeSpy = vi.spyOn(document.body, 'removeChild')
41
-
42
- await expect(
43
- triggerDownload('https://cdn.example.com/file.pdf', 'report.pdf')
44
- ).resolves.toBeUndefined()
45
-
46
- expect(fetchMock).toHaveBeenCalledWith('https://cdn.example.com/file.pdf', {
47
- mode: 'cors',
48
- })
49
- expect(createObjectURLMock).toHaveBeenCalledWith(blob)
50
- expect(clickMock).toHaveBeenCalledOnce()
51
- expect(revokeObjectURLMock).toHaveBeenCalledWith('blob:attachment')
52
- const anchor = appendSpy.mock.calls.at(-1)?.[0] as HTMLAnchorElement
53
- expect(anchor.href).toBe('blob:attachment')
54
- expect(anchor.download).toBe('report.pdf')
55
- expect(anchor.style.display).toBe('none')
56
- expect(removeSpy).toHaveBeenCalledWith(anchor)
57
- })
58
-
59
- it('uses the URL filename when no filename is supplied', async () => {
60
- fetchMock.mockResolvedValue({
61
- ok: true,
62
- blob: () => Promise.resolve(new Blob()),
63
- })
64
- createObjectURLMock.mockReturnValue('blob:attachment')
65
- const appendSpy = vi.spyOn(document.body, 'appendChild')
66
-
67
- await triggerDownload('https://cdn.example.com/a/My%20file.pdf')
68
-
69
- expect(
70
- (appendSpy.mock.calls.at(-1)?.[0] as HTMLAnchorElement).download
71
- ).toBe('My file.pdf')
72
- })
73
-
74
- it.each(['a non-ok response', 'a rejected fetch'])(
75
- 'falls back to a new window for %s',
76
- async (label) => {
77
- if (label === 'a non-ok response') {
78
- fetchMock.mockResolvedValue({ ok: false, status: 404 })
79
- } else {
80
- fetchMock.mockRejectedValue(new Error('offline'))
81
- }
82
- openMock.mockReturnValue({})
83
-
84
- await expect(
85
- triggerDownload('https://cdn.example.com/file.pdf')
86
- ).resolves.toBeUndefined()
87
-
88
- expect(openMock).toHaveBeenCalledWith(
89
- 'https://cdn.example.com/file.pdf',
90
- '_blank',
91
- 'noopener,noreferrer'
92
- )
93
- }
94
- )
95
-
96
- it('uses a raw-url anchor when the popup is blocked', async () => {
97
- fetchMock.mockRejectedValue(new Error('offline'))
98
- openMock.mockReturnValue(null)
99
- const appendSpy = vi.spyOn(document.body, 'appendChild')
100
- const removeSpy = vi.spyOn(document.body, 'removeChild')
101
-
102
- await expect(
103
- triggerDownload('https://cdn.example.com/file.pdf')
104
- ).resolves.toBeUndefined()
105
-
106
- const anchor = appendSpy.mock.calls.at(-1)?.[0] as HTMLAnchorElement
107
- expect(anchor.href).toBe('https://cdn.example.com/file.pdf')
108
- expect(anchor.download).toBe('file.pdf')
109
- expect(anchor.target).toBe('_blank')
110
- expect(anchor.rel).toBe('noopener noreferrer')
111
- expect(removeSpy).toHaveBeenCalledWith(anchor)
112
- })
113
- })
@@ -1,68 +0,0 @@
1
- import { describe, expect, it } from 'vitest'
2
-
3
- import { fireEvent, renderWithProviders, screen } from '../../../test/utils'
4
-
5
- import { useCarousel } from './useCarousel'
6
-
7
- const Harness = ({
8
- open,
9
- length,
10
- initialIndex = 0,
11
- }: {
12
- open: boolean
13
- length: number
14
- initialIndex?: number
15
- }) => {
16
- const { index, prev, next } = useCarousel({ open, length, initialIndex })
17
- return (
18
- <div>
19
- <span data-testid="index">{index}</span>
20
- <button type="button" onClick={prev}>
21
- Prev
22
- </button>
23
- <button type="button" onClick={next}>
24
- Next
25
- </button>
26
- </div>
27
- )
28
- }
29
-
30
- describe('useCarousel', () => {
31
- it('clamps initial index, wraps, and resets on reopen', () => {
32
- const { rerender } = renderWithProviders(
33
- <Harness open length={3} initialIndex={99} />
34
- )
35
- expect(screen.getByTestId('index')).toHaveTextContent('2')
36
- fireEvent.click(screen.getByRole('button', { name: 'Next' }))
37
- expect(screen.getByTestId('index')).toHaveTextContent('0')
38
- fireEvent.click(screen.getByRole('button', { name: 'Prev' }))
39
- expect(screen.getByTestId('index')).toHaveTextContent('2')
40
- rerender(<Harness open={false} length={3} initialIndex={1} />)
41
- rerender(<Harness open length={3} initialIndex={1} />)
42
- expect(screen.getByTestId('index')).toHaveTextContent('1')
43
- })
44
-
45
- it('handles arrows only when open with multiple items', () => {
46
- const { rerender } = renderWithProviders(
47
- <Harness open length={2} initialIndex={0} />
48
- )
49
- const right = fireEvent.keyDown(window, { key: 'ArrowRight' })
50
- expect(screen.getByTestId('index')).toHaveTextContent('1')
51
- expect(right).toBe(false)
52
- for (const tagName of ['video', 'audio'] as const) {
53
- const media = document.createElement(tagName)
54
- media.tabIndex = 0
55
- document.body.appendChild(media)
56
- media.focus()
57
- fireEvent.keyDown(window, { key: 'ArrowLeft' })
58
- expect(screen.getByTestId('index')).toHaveTextContent('1')
59
- media.remove()
60
- }
61
- rerender(<Harness open length={1} />)
62
- fireEvent.keyDown(window, { key: 'ArrowRight' })
63
- expect(screen.getByTestId('index')).toHaveTextContent('0')
64
- rerender(<Harness open={false} length={2} />)
65
- fireEvent.keyDown(window, { key: 'ArrowRight' })
66
- expect(screen.getByTestId('index')).toHaveTextContent('0')
67
- })
68
- })
@@ -1,16 +0,0 @@
1
- import { describe, expect, it } from 'vitest'
2
-
3
- import { VIEWER_ACTION_CLASS, VIEWER_NAV_CLASS } from './viewerChromeClasses'
4
-
5
- describe('viewer chrome classes', () => {
6
- it('keeps the load-bearing action button tokens', () => {
7
- expect(VIEWER_ACTION_CLASS).toContain('size-10')
8
- expect(VIEWER_ACTION_CLASS).toContain('rounded-full')
9
- })
10
-
11
- it('keeps the load-bearing navigation button tokens', () => {
12
- expect(VIEWER_NAV_CLASS).toContain('absolute')
13
- expect(VIEWER_NAV_CLASS).toContain('size-12')
14
- expect(VIEWER_NAV_CLASS).toContain('-translate-y-1/2')
15
- })
16
- })
@@ -1,136 +0,0 @@
1
- import type { Logger } from '@linktr.ee/messaging-core'
2
- import { render } from '@testing-library/react'
3
- import React from 'react'
4
- import { describe, it, expect, vi, afterEach } from 'vitest'
5
-
6
- import {
7
- MessagingLoggerProvider,
8
- consoleLogger,
9
- resolveLogger,
10
- useMessagingLogger,
11
- } from './index'
12
-
13
- const ERROR_MESSAGE = '[MessagingShell] Failed to load channels:'
14
-
15
- /** Logs one error through the seam the way a call site would. */
16
- const ErrorEmitter: React.FC<{ error: Error }> = ({ error }) => {
17
- const logger = useMessagingLogger()
18
- logger.error(ERROR_MESSAGE, error)
19
- return null
20
- }
21
-
22
- /** Emits one line at every level so partial-sink fallback is observable. */
23
- const AllLevelsEmitter: React.FC = () => {
24
- const logger = useMessagingLogger()
25
- logger.debug('debug line', 1)
26
- logger.info('info line', 2)
27
- logger.warn('warn line', 3)
28
- logger.error('error line', 4)
29
- return null
30
- }
31
-
32
- afterEach(() => {
33
- vi.restoreAllMocks()
34
- })
35
-
36
- describe('logger seam', () => {
37
- it('routes error to an injected sink with the exact message and raw error object', () => {
38
- const error = new Error('boom')
39
- const sink = { error: vi.fn() }
40
-
41
- render(
42
- <MessagingLoggerProvider logger={sink}>
43
- <ErrorEmitter error={error} />
44
- </MessagingLoggerProvider>
45
- )
46
-
47
- expect(sink.error).toHaveBeenCalledTimes(1)
48
- expect(sink.error).toHaveBeenCalledWith(ERROR_MESSAGE, error)
49
- // The raw Error instance, not a serialized copy.
50
- expect(sink.error.mock.calls[0][1]).toBe(error)
51
- })
52
-
53
- it('passes identical arguments to console.error when no sink is injected', () => {
54
- const error = new Error('boom')
55
- const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
56
- const sink = { error: vi.fn() }
57
-
58
- render(
59
- <MessagingLoggerProvider logger={sink}>
60
- <ErrorEmitter error={error} />
61
- </MessagingLoggerProvider>
62
- )
63
- const injectedArgs = sink.error.mock.calls[0]
64
-
65
- // No provider at all, and a provider with no logger prop: both must produce
66
- // byte-identical console output to what the injected sink received.
67
- render(<ErrorEmitter error={error} />)
68
- render(
69
- <MessagingLoggerProvider>
70
- <ErrorEmitter error={error} />
71
- </MessagingLoggerProvider>
72
- )
73
-
74
- expect(consoleError).toHaveBeenCalledTimes(2)
75
- expect(consoleError.mock.calls[0]).toEqual(injectedArgs)
76
- expect(consoleError.mock.calls[1]).toEqual(injectedArgs)
77
- expect(consoleError.mock.calls[0][1]).toBe(error)
78
- })
79
-
80
- it('falls back to console for the levels a partial sink omits', () => {
81
- const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {})
82
- const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {})
83
- const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
84
- const sink: Logger = { warn: vi.fn(), error: vi.fn() }
85
-
86
- render(
87
- <MessagingLoggerProvider logger={sink}>
88
- <AllLevelsEmitter />
89
- </MessagingLoggerProvider>
90
- )
91
-
92
- expect(sink.warn).toHaveBeenCalledWith('warn line', 3)
93
- expect(sink.error).toHaveBeenCalledWith('error line', 4)
94
- expect(consoleWarn).not.toHaveBeenCalled()
95
- expect(consoleError).not.toHaveBeenCalled()
96
- // debug and info are unset on the sink, so both land on console.log.
97
- expect(consoleLog.mock.calls).toEqual([
98
- ['debug line', 1],
99
- ['info line', 2],
100
- ])
101
- })
102
-
103
- it('defaults every level to console when the hook is used with no provider', () => {
104
- const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {})
105
- const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {})
106
- const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
107
-
108
- render(<AllLevelsEmitter />)
109
-
110
- expect(consoleLog.mock.calls).toEqual([
111
- ['debug line', 1],
112
- ['info line', 2],
113
- ])
114
- expect(consoleWarn).toHaveBeenCalledWith('warn line', 3)
115
- expect(consoleError).toHaveBeenCalledWith('error line', 4)
116
- })
117
-
118
- it('resolveLogger keeps provided levels and fills the rest from console', () => {
119
- const warn = vi.fn()
120
- const resolved = resolveLogger({ warn })
121
-
122
- expect(resolved.warn).toBe(warn)
123
- expect(resolved.debug).toBe(consoleLogger.debug)
124
- expect(resolved.info).toBe(consoleLogger.info)
125
- expect(resolved.error).toBe(consoleLogger.error)
126
- expect(resolveLogger()).toEqual(consoleLogger)
127
- })
128
-
129
- it('consoleLogger dispatches through console at call time, not bind time', () => {
130
- const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
131
-
132
- consoleLogger.error('late spy', 'arg')
133
-
134
- expect(consoleError).toHaveBeenCalledWith('late spy', 'arg')
135
- })
136
- })
@@ -1,48 +0,0 @@
1
- import type { Logger } from '@linktr.ee/messaging-core'
2
- import React, { createContext, useContext, useMemo } from 'react'
3
-
4
- /**
5
- * Default sink: the console calls the package makes today. Wrappers, not
6
- * `console.error.bind(console)`, so a later-installed spy is still observed.
7
- */
8
- export const consoleLogger: Required<Logger> = {
9
- debug: (message, ...args) => console.log(message, ...args),
10
- info: (message, ...args) => console.log(message, ...args),
11
- warn: (message, ...args) => console.warn(message, ...args),
12
- error: (message, ...args) => console.error(message, ...args),
13
- }
14
-
15
- /**
16
- * Merge a partial sink over the console defaults, so a consumer can route only
17
- * the levels it cares about (e.g. `error`) and leave the rest on console.
18
- */
19
- export const resolveLogger = (logger?: Logger): Required<Logger> => ({
20
- debug: logger?.debug ?? consoleLogger.debug,
21
- info: logger?.info ?? consoleLogger.info,
22
- warn: logger?.warn ?? consoleLogger.warn,
23
- error: logger?.error ?? consoleLogger.error,
24
- })
25
-
26
- const LoggerContext = createContext<Required<Logger>>(consoleLogger)
27
-
28
- /**
29
- * Provides the resolved logger sink to the messaging subtree. Rendered by
30
- * `MessagingProvider`; usable standalone in tests or in a subtree that needs a
31
- * different sink.
32
- */
33
- export const MessagingLoggerProvider: React.FC<{
34
- logger?: Logger
35
- children: React.ReactNode
36
- }> = ({ logger, children }) => {
37
- const value = useMemo(() => resolveLogger(logger), [logger])
38
- return (
39
- <LoggerContext.Provider value={value}>{children}</LoggerContext.Provider>
40
- )
41
- }
42
-
43
- /**
44
- * The logger sink for the current subtree. Falls back to console when no
45
- * provider (or no `logger` prop) is present, so call sites never branch.
46
- */
47
- export const useMessagingLogger = (): Required<Logger> =>
48
- useContext(LoggerContext)
@@ -1,202 +0,0 @@
1
- import '../stream-custom-data'
2
-
3
- import type { StoryFn } from '@storybook/react'
4
- import React, { useEffect } from 'react'
5
- import {
6
- Channel as ChannelType,
7
- QueryChannelAPIResponse,
8
- StreamChat,
9
- } from 'stream-chat'
10
- import {
11
- Channel,
12
- Chat,
13
- MessageList,
14
- MessageUIComponentProps,
15
- Window,
16
- } from 'stream-chat-react'
17
-
18
- import { CustomMessage } from '../components/CustomMessage'
19
- import {
20
- OutboundContext,
21
- buildOutboundsByRecordId,
22
- } from '../components/CustomMessage/OutboundContext'
23
- import CustomTypingIndicator from '../components/CustomTypingIndicator'
24
- import { createMockStreamChatClient } from '../testing/createMockStreamChatClient'
25
- import type { OutboundResolver } from '../types'
26
-
27
- import { minutesAgo } from './decorators/storyTime'
28
- import { StoryUser, storyUsers } from './decorators/storyUser'
29
-
30
- export const createMockChannel = async (
31
- client: StreamChat,
32
- messages: TemplateProps['messages']
33
- ) => {
34
- const mockMessages = messages.map((msg, index) => ({
35
- ...msg,
36
- type: msg.type ?? ('regular' as const),
37
- created_at: minutesAgo(messages.length - index),
38
- updated_at: minutesAgo(messages.length - index),
39
- attachments: msg.attachments ?? [],
40
- latest_reactions: [],
41
- own_reactions: [],
42
- reaction_counts: {},
43
- reaction_scores: {},
44
- reply_count: 0,
45
- status: 'received',
46
- cid: 'messaging:storybook-channel-1',
47
- mentioned_users: msg.mentioned_users ?? [],
48
- }))
49
-
50
- const channelData = {
51
- members: [storyUsers.creator.id, storyUsers.visitor.id],
52
- // Required for useUserRole: canDelete needs delete-own-message, canFlag needs flag-message
53
- own_capabilities: ['delete-own-message', 'flag-message'],
54
- }
55
-
56
- const channel = client.channel(
57
- 'messaging',
58
- 'storybook-channel-1',
59
- channelData
60
- )
61
-
62
- channel.watch = async () => {
63
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
64
- channel.state.messages = mockMessages as unknown as any[]
65
- channel.state.members = {
66
- [storyUsers.creator.id]: {
67
- user: storyUsers.creator,
68
- user_id: storyUsers.creator.id,
69
- },
70
- [storyUsers.visitor.id]: {
71
- user: storyUsers.visitor,
72
- user_id: storyUsers.visitor.id,
73
- },
74
- }
75
- return {
76
- channel: channelData,
77
- members: [],
78
- messages: mockMessages,
79
- watchers: [],
80
- pinned_messages: [],
81
- duration: '0ms',
82
- } as unknown as QueryChannelAPIResponse
83
- }
84
-
85
- try {
86
- await channel.watch()
87
- } catch {
88
- // Ignore errors in mock mode
89
- }
90
-
91
- return channel
92
- }
93
-
94
- export interface TemplateProps {
95
- currentUser: StoryUser
96
- typingUser?: StoryUser
97
- resolveOutbound?: OutboundResolver
98
- messages: Array<{
99
- id: string
100
- text: string
101
- user: StoryUser
102
- mentioned_users?: StoryUser[]
103
- type?: 'regular' | 'system'
104
- attachments?: Array<Record<string, unknown>>
105
- metadata?: {
106
- custom_type?:
107
- | 'MESSAGE_TIP'
108
- | 'MESSAGE_PAID'
109
- | 'MESSAGE_CHATBOT'
110
- | 'MESSAGE_ATTACHMENT'
111
- | 'MESSAGE_OUTBOUND'
112
- payment_status?: 'pending' | 'paid' | 'failed' | 'refunded'
113
- amount_text?: string
114
- attachment_title?: string
115
- attachment_mime_type?: string
116
- attachment_thumbnail?: string
117
- attachment_detail?: string
118
- attachment_content_type?: 'text' | 'media'
119
- outbound_id?: string
120
- }
121
- }>
122
- }
123
-
124
- const TemplateInner: React.FC<{
125
- currentUser: StoryUser
126
- typingUser?: StoryUser
127
- messages: TemplateProps['messages']
128
- resolveOutbound?: OutboundResolver
129
- }> = ({ currentUser, typingUser, messages, resolveOutbound }) => {
130
- const [client] = React.useState(() => createMockStreamChatClient(currentUser))
131
-
132
- const [channel, setChannel] = React.useState<ChannelType | null>(null)
133
-
134
- useEffect(() => {
135
- createMockChannel(client, messages).then(setChannel)
136
- }, [client, messages])
137
-
138
- useEffect(() => {
139
- if (!channel || !typingUser) {
140
- return
141
- }
142
-
143
- // Stream Channel UI only updates typing context from typing.start/typing.stop events.
144
- const timer = setTimeout(() => {
145
- client.dispatchEvent({
146
- type: 'typing.start',
147
- cid: channel.cid,
148
- user: typingUser,
149
- })
150
- }, 0)
151
-
152
- return () => clearTimeout(timer)
153
- }, [channel, client, typingUser])
154
-
155
- const outboundsByRecordId = React.useMemo(
156
- () => buildOutboundsByRecordId(messages, resolveOutbound),
157
- [messages, resolveOutbound]
158
- )
159
-
160
- const MessageComponent = React.useMemo(() => {
161
- return function CustomMessageComponent(props: MessageUIComponentProps) {
162
- return <CustomMessage {...props} onOutboundClick={() => undefined} />
163
- }
164
- }, [])
165
-
166
- if (!channel) {
167
- return <div className="p-4">Loading...</div>
168
- }
169
-
170
- return (
171
- <OutboundContext.Provider value={outboundsByRecordId}>
172
- <Chat client={client}>
173
- <div className="h-screen w-full bg-white">
174
- <Channel
175
- channel={channel}
176
- Message={MessageComponent}
177
- TypingIndicator={CustomTypingIndicator}
178
- >
179
- <Window>
180
- <MessageList />
181
- </Window>
182
- </Channel>
183
- </div>
184
- </Chat>
185
- </OutboundContext.Provider>
186
- )
187
- }
188
-
189
- export const Template: StoryFn<TemplateProps> = ({
190
- currentUser = storyUsers.creator,
191
- typingUser,
192
- messages,
193
- resolveOutbound,
194
- }) => (
195
- <TemplateInner
196
- key={currentUser.id}
197
- currentUser={currentUser}
198
- typingUser={typingUser}
199
- messages={messages}
200
- resolveOutbound={resolveOutbound}
201
- />
202
- )