@linktr.ee/messaging-react 4.2.1-rc-1787325541 → 4.2.1
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/dist/{Card-SKwZNuBP.js → Card-3VtiPkQH.js} +2 -2
- package/dist/{Card-SKwZNuBP.js.map → Card-3VtiPkQH.js.map} +1 -1
- package/dist/{Card-DEyEXhqc.cjs → Card-BxYYijOd.cjs} +2 -2
- package/dist/{Card-DEyEXhqc.cjs.map → Card-BxYYijOd.cjs.map} +1 -1
- package/dist/index-Cd-uS2hf.cjs +3 -0
- package/dist/index-Cd-uS2hf.cjs.map +1 -0
- package/dist/{index-Dp0HCBIW.js → index-wG3iH9e6.js} +1829 -1814
- package/dist/index-wG3iH9e6.js.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +26 -0
- package/dist/index.js +28 -26
- package/package.json +2 -2
- package/src/components/AttachmentCard/AttachmentCard.stories.tsx +36 -0
- package/src/components/AttachmentCard/Thumbnail.tsx +2 -2
- package/src/components/MessageAttachment/_shared/MediaStackGrid.test.tsx +0 -28
- package/src/components/MessageAttachment/_shared/MediaStackGrid.tsx +8 -16
- package/src/index.ts +3 -0
- package/src/logging/index.test.tsx +136 -0
- package/src/logging/index.tsx +48 -0
- package/src/providers/MessagingProvider.test.tsx +127 -1
- package/src/providers/MessagingProvider.tsx +26 -15
- package/src/types.ts +8 -0
- package/dist/index-BQCvCIsC.cjs +0 -3
- package/dist/index-BQCvCIsC.cjs.map +0 -1
- package/dist/index-Dp0HCBIW.js.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-
|
|
1
|
+
"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("./index-Cd-uS2hf.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.MessageBubble=e.MessageBubble;exports.MessagingLoggerProvider=e.MessagingLoggerProvider;exports.MessagingProvider=e.MessagingProvider;exports.MessagingShell=e.MessagingShell;exports.RichCard=e.RichCard;exports.TipMessage=e.TipMessage;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.isTipMessage=e.isTipMessage;exports.isUuidLike=e.isUuidLike;exports.messageAttachmentGroupPositionFromStream=e.bubbleGroupPositionFromStream;exports.normalizeLanguageCode=e.normalizeLanguageCode;exports.optimizeMessagingAttachmentUrl=e.optimizeMessagingAttachmentUrl;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.useMessaging=e.useMessaging;exports.useMessagingLogger=e.useMessagingLogger;
|
|
2
2
|
//# sourceMappingURL=index.cjs.map
|
package/dist/index.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { FC } from 'react';
|
|
|
13
13
|
import { InfiniteScrollProps } from 'stream-chat-react';
|
|
14
14
|
import { JSX as JSX_2 } from 'react/jsx-runtime';
|
|
15
15
|
import { LocalMessage } from 'stream-chat';
|
|
16
|
+
import { Logger } from '@linktr.ee/messaging-core';
|
|
16
17
|
import { MessageCustomType } from '@linktr.ee/messaging-taxonomy';
|
|
17
18
|
import { MessagingUser } from '@linktr.ee/messaging-core';
|
|
18
19
|
import { OfficialCtaActionKind } from '@linktr.ee/messaging-taxonomy';
|
|
@@ -872,6 +873,8 @@ export declare interface LockedAttachmentSource {
|
|
|
872
873
|
thumbnailUrl?: string;
|
|
873
874
|
}
|
|
874
875
|
|
|
876
|
+
export { Logger }
|
|
877
|
+
|
|
875
878
|
declare interface MediaCardBaseProps {
|
|
876
879
|
/** Attachment title. */
|
|
877
880
|
title?: string;
|
|
@@ -1442,6 +1445,16 @@ declare interface MessagingContextValue {
|
|
|
1442
1445
|
debug: boolean;
|
|
1443
1446
|
}
|
|
1444
1447
|
|
|
1448
|
+
/**
|
|
1449
|
+
* Provides the resolved logger sink to the messaging subtree. Rendered by
|
|
1450
|
+
* `MessagingProvider`; usable standalone in tests or in a subtree that needs a
|
|
1451
|
+
* different sink.
|
|
1452
|
+
*/
|
|
1453
|
+
export declare const MessagingLoggerProvider: default_2.FC<{
|
|
1454
|
+
logger?: Logger;
|
|
1455
|
+
children: default_2.ReactNode;
|
|
1456
|
+
}>;
|
|
1457
|
+
|
|
1445
1458
|
/**
|
|
1446
1459
|
* Provider component that wraps messaging-core with React state management
|
|
1447
1460
|
*/
|
|
@@ -1463,6 +1476,13 @@ declare interface MessagingProviderBaseProps {
|
|
|
1463
1476
|
user: MessagingUser | null;
|
|
1464
1477
|
capabilities?: MessagingCapabilities;
|
|
1465
1478
|
debug?: boolean;
|
|
1479
|
+
/**
|
|
1480
|
+
* Optional sink for the package's own log output. Levels left undefined fall
|
|
1481
|
+
* back to console, which is also the default when the prop is omitted. In
|
|
1482
|
+
* apiKey mode it is forwarded to the underlying StreamChatService unless
|
|
1483
|
+
* `serviceConfig.logger` is set, which wins.
|
|
1484
|
+
*/
|
|
1485
|
+
logger?: Logger;
|
|
1466
1486
|
}
|
|
1467
1487
|
|
|
1468
1488
|
/**
|
|
@@ -1825,6 +1845,12 @@ export declare function useCustomMessage<K extends keyof CustomMessageRegistry>(
|
|
|
1825
1845
|
*/
|
|
1826
1846
|
export declare const useMessaging: () => MessagingContextValue;
|
|
1827
1847
|
|
|
1848
|
+
/**
|
|
1849
|
+
* The logger sink for the current subtree. Falls back to console when no
|
|
1850
|
+
* provider (or no `logger` prop) is present, so call sites never branch.
|
|
1851
|
+
*/
|
|
1852
|
+
export declare const useMessagingLogger: () => Required<Logger>;
|
|
1853
|
+
|
|
1828
1854
|
declare interface VideoAttachmentSharedProps extends MessageAttachmentBaseProps {
|
|
1829
1855
|
/** Single video — convenience for the most common case. */
|
|
1830
1856
|
src?: string;
|
package/dist/index.js
CHANGED
|
@@ -1,39 +1,41 @@
|
|
|
1
|
-
import { a as e, b as t, C as i, c as n, d as o, e as
|
|
1
|
+
import { a as e, b as t, C as i, c as n, d as o, e as g, F as r, f as m, L as l, h as M, M as c, i as L, j as h, k as p, l as u, m as d, R as v, T as C, n as A, p as k, q as F, s as b, t as P, u as T, v as f, w as x, x as y, y as z, o as S, z as q, B, D, E, G as R, H as w, I as G, J as I } from "./index-wG3iH9e6.js";
|
|
2
2
|
export {
|
|
3
3
|
e as ActionButton,
|
|
4
4
|
t as Avatar,
|
|
5
5
|
i as ChannelEmptyState,
|
|
6
6
|
n as ChannelList,
|
|
7
7
|
o as ChannelView,
|
|
8
|
-
|
|
8
|
+
g as CustomMessageProvider,
|
|
9
9
|
r as FaqList,
|
|
10
|
-
|
|
10
|
+
m as FaqListItem,
|
|
11
11
|
l as LinkAttachment,
|
|
12
12
|
M as LockedAttachment,
|
|
13
13
|
c as MediaMessage,
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
u as
|
|
18
|
-
d as
|
|
14
|
+
L as MessageAttachment,
|
|
15
|
+
h as MessageBubble,
|
|
16
|
+
p as MessagingLoggerProvider,
|
|
17
|
+
u as MessagingProvider,
|
|
18
|
+
d as MessagingShell,
|
|
19
|
+
v as RichCard,
|
|
19
20
|
C as TipMessage,
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
21
|
+
A as buildCompactMetaLabel,
|
|
22
|
+
k as formatFileSize,
|
|
23
|
+
F as formatRelativeTime,
|
|
24
|
+
b as getFileExtensionLabel,
|
|
25
|
+
P as getMessageDisplayText,
|
|
26
|
+
T as isLinkAttachment,
|
|
27
|
+
f as isTipMessage,
|
|
28
|
+
x as isUuidLike,
|
|
29
|
+
y as messageAttachmentGroupPositionFromStream,
|
|
30
|
+
z as normalizeLanguageCode,
|
|
31
|
+
S as optimizeMessagingAttachmentUrl,
|
|
32
|
+
q as resolveConversationParticipant,
|
|
33
|
+
B as resolveLinkAttachment,
|
|
34
|
+
D as resolveMediaFromMessage,
|
|
35
|
+
E as resolveParticipantDisplayName,
|
|
36
|
+
R as useComposerLocked,
|
|
37
|
+
w as useCustomMessage,
|
|
38
|
+
G as useMessaging,
|
|
39
|
+
I as useMessagingLogger
|
|
38
40
|
};
|
|
39
41
|
//# sourceMappingURL=index.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linktr.ee/messaging-react",
|
|
3
|
-
"version": "4.2.1
|
|
3
|
+
"version": "4.2.1",
|
|
4
4
|
"description": "React messaging components built on messaging-core for web applications",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.cjs",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"chromatic:local": "yarn storybook:build && chromatic"
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@linktr.ee/messaging-core": "2.5.0
|
|
53
|
+
"@linktr.ee/messaging-core": "^2.5.0",
|
|
54
54
|
"@linktr.ee/messaging-taxonomy": "^0.10.0",
|
|
55
55
|
"@phosphor-icons/react": "^2.1.10"
|
|
56
56
|
},
|
|
@@ -149,6 +149,42 @@ export const AllVariants: StoryFn = () => {
|
|
|
149
149
|
),
|
|
150
150
|
},
|
|
151
151
|
},
|
|
152
|
+
{
|
|
153
|
+
label: 'Contained image (dark)',
|
|
154
|
+
props: {
|
|
155
|
+
variant: 'dark',
|
|
156
|
+
title: 'portrait.jpg',
|
|
157
|
+
mimeType: 'image/jpeg',
|
|
158
|
+
detail: '1.8 MB',
|
|
159
|
+
thumbnail: (
|
|
160
|
+
<AttachmentThumbnail
|
|
161
|
+
mimeType="image/jpeg"
|
|
162
|
+
sourceUrl={IMAGE_URL}
|
|
163
|
+
title="portrait"
|
|
164
|
+
variant="dark"
|
|
165
|
+
containedImage
|
|
166
|
+
/>
|
|
167
|
+
),
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
label: 'Contained image (light)',
|
|
172
|
+
props: {
|
|
173
|
+
variant: 'light',
|
|
174
|
+
title: 'portrait.jpg',
|
|
175
|
+
mimeType: 'image/jpeg',
|
|
176
|
+
detail: '1.8 MB',
|
|
177
|
+
thumbnail: (
|
|
178
|
+
<AttachmentThumbnail
|
|
179
|
+
mimeType="image/jpeg"
|
|
180
|
+
sourceUrl={IMAGE_URL}
|
|
181
|
+
title="portrait"
|
|
182
|
+
variant="light"
|
|
183
|
+
containedImage
|
|
184
|
+
/>
|
|
185
|
+
),
|
|
186
|
+
},
|
|
187
|
+
},
|
|
152
188
|
{
|
|
153
189
|
label: 'PDF placeholder',
|
|
154
190
|
props: {
|
|
@@ -68,7 +68,7 @@ const AttachmentThumbnail: React.FC<AttachmentThumbnailProps> = ({
|
|
|
68
68
|
if (sourceUrl && sourceType === 'image') {
|
|
69
69
|
if (containedImage) {
|
|
70
70
|
return (
|
|
71
|
-
<div className=
|
|
71
|
+
<div className={`relative ${posterShellClass(variant)}`}>
|
|
72
72
|
<img
|
|
73
73
|
src={optimizedSourceUrl ?? sourceUrl}
|
|
74
74
|
alt={title ?? ''}
|
|
@@ -93,7 +93,7 @@ const AttachmentThumbnail: React.FC<AttachmentThumbnailProps> = ({
|
|
|
93
93
|
if (thumbnailUrl) {
|
|
94
94
|
if (containedImage) {
|
|
95
95
|
return (
|
|
96
|
-
<div className=
|
|
96
|
+
<div className={`relative ${posterShellClass(variant)}`}>
|
|
97
97
|
<img
|
|
98
98
|
src={optimizedThumbnailUrl ?? thumbnailUrl}
|
|
99
99
|
alt={title ?? ''}
|
|
@@ -47,34 +47,6 @@ describe('MediaStackGrid', () => {
|
|
|
47
47
|
})
|
|
48
48
|
})
|
|
49
49
|
|
|
50
|
-
it.each([
|
|
51
|
-
[2, '+3', 'tile 2'],
|
|
52
|
-
[3, '+2', 'tile 3'],
|
|
53
|
-
])(
|
|
54
|
-
'shows overflow on the last visible tile when maxVisible is %s',
|
|
55
|
-
(maxVisible, badge, lastTile) => {
|
|
56
|
-
const { container } = renderWithProviders(
|
|
57
|
-
<MediaStackGrid tiles={tiles(5)} maxVisible={maxVisible} />
|
|
58
|
-
)
|
|
59
|
-
expect(container.querySelectorAll('span')).toHaveLength(maxVisible)
|
|
60
|
-
expect(screen.getByText(badge)).toBeInTheDocument()
|
|
61
|
-
expect(screen.getByText(badge).parentElement).toHaveTextContent(lastTile)
|
|
62
|
-
}
|
|
63
|
-
)
|
|
64
|
-
|
|
65
|
-
it('shows overflow on the single visible tile', () => {
|
|
66
|
-
const { container } = renderWithProviders(
|
|
67
|
-
<MediaStackGrid tiles={tiles(3)} maxVisible={1} />
|
|
68
|
-
)
|
|
69
|
-
expect(container.querySelectorAll('span')).toHaveLength(1)
|
|
70
|
-
expect(screen.getByText('+2').parentElement).toHaveTextContent('tile 1')
|
|
71
|
-
})
|
|
72
|
-
|
|
73
|
-
it('does not show overflow when all tiles are visible', () => {
|
|
74
|
-
renderWithProviders(<MediaStackGrid tiles={tiles(3)} maxVisible={3} />)
|
|
75
|
-
expect(screen.queryByText(/^\+/)).toBeNull()
|
|
76
|
-
})
|
|
77
|
-
|
|
78
50
|
it('renders activatable buttons with labels and indexes', () => {
|
|
79
51
|
const onActivate = vi.fn()
|
|
80
52
|
renderWithProviders(
|
|
@@ -118,12 +118,6 @@ const MediaStackGrid: React.FC<MediaStackGridProps> = ({
|
|
|
118
118
|
|
|
119
119
|
const visible = tiles.slice(0, Math.min(total, maxVisible))
|
|
120
120
|
const overflow = total - visible.length
|
|
121
|
-
const overflowBadge =
|
|
122
|
-
overflow > 0 ? (
|
|
123
|
-
<div className="absolute inset-0 flex items-center justify-center bg-black/55 text-2xl font-semibold text-white">
|
|
124
|
-
+{overflow}
|
|
125
|
-
</div>
|
|
126
|
-
) : null
|
|
127
121
|
|
|
128
122
|
const renderTile = (
|
|
129
123
|
tile: MediaStackTile,
|
|
@@ -180,7 +174,7 @@ const MediaStackGrid: React.FC<MediaStackGridProps> = ({
|
|
|
180
174
|
)}
|
|
181
175
|
style={singleTileStyle}
|
|
182
176
|
>
|
|
183
|
-
{renderTile(visible[0], 0
|
|
177
|
+
{renderTile(visible[0], 0)}
|
|
184
178
|
</div>
|
|
185
179
|
)
|
|
186
180
|
}
|
|
@@ -193,13 +187,7 @@ const MediaStackGrid: React.FC<MediaStackGridProps> = ({
|
|
|
193
187
|
className
|
|
194
188
|
)}
|
|
195
189
|
>
|
|
196
|
-
{visible.map((tile, index) =>
|
|
197
|
-
renderTile(
|
|
198
|
-
tile,
|
|
199
|
-
index,
|
|
200
|
-
index === visible.length - 1 ? overflowBadge : null
|
|
201
|
-
)
|
|
202
|
-
)}
|
|
190
|
+
{visible.map((tile, index) => renderTile(tile, index))}
|
|
203
191
|
</div>
|
|
204
192
|
)
|
|
205
193
|
}
|
|
@@ -214,7 +202,7 @@ const MediaStackGrid: React.FC<MediaStackGridProps> = ({
|
|
|
214
202
|
>
|
|
215
203
|
<div className="row-span-2">{renderTile(visible[0], 0)}</div>
|
|
216
204
|
{renderTile(visible[1], 1)}
|
|
217
|
-
{renderTile(visible[2], 2
|
|
205
|
+
{renderTile(visible[2], 2)}
|
|
218
206
|
</div>
|
|
219
207
|
)
|
|
220
208
|
}
|
|
@@ -239,7 +227,11 @@ const MediaStackGrid: React.FC<MediaStackGridProps> = ({
|
|
|
239
227
|
return renderTile(
|
|
240
228
|
tile,
|
|
241
229
|
index,
|
|
242
|
-
isLastWithOverflow ?
|
|
230
|
+
isLastWithOverflow ? (
|
|
231
|
+
<div className="absolute inset-0 flex items-center justify-center bg-black/55 text-2xl font-semibold text-white">
|
|
232
|
+
+{overflow}
|
|
233
|
+
</div>
|
|
234
|
+
) : null
|
|
243
235
|
)
|
|
244
236
|
})}
|
|
245
237
|
</div>
|
package/src/index.ts
CHANGED
|
@@ -29,11 +29,13 @@ export type {
|
|
|
29
29
|
|
|
30
30
|
// Providers
|
|
31
31
|
export { MessagingProvider } from './providers/MessagingProvider'
|
|
32
|
+
export { MessagingLoggerProvider } from './logging'
|
|
32
33
|
export { CustomMessageProvider } from './components/CustomMessage/context'
|
|
33
34
|
|
|
34
35
|
// Hooks
|
|
35
36
|
export { useComposerLocked } from './components/CustomMessageInput'
|
|
36
37
|
export { useMessaging } from './hooks/useMessaging'
|
|
38
|
+
export { useMessagingLogger } from './logging'
|
|
37
39
|
export { useCustomMessage } from './components/CustomMessage/context'
|
|
38
40
|
|
|
39
41
|
// Utils
|
|
@@ -136,3 +138,4 @@ export type { CustomMessageRegistry } from './components/CustomMessage/context'
|
|
|
136
138
|
export type { AttachmentSourceType } from './components/AttachmentCard/utils/mimeType'
|
|
137
139
|
export type { Faq, FaqListProps } from './components/FaqList'
|
|
138
140
|
export type { FaqListItemProps } from './components/FaqList/FaqListItem'
|
|
141
|
+
export type { Logger } from '@linktr.ee/messaging-core'
|
|
@@ -0,0 +1,136 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
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,10 +1,16 @@
|
|
|
1
1
|
import { StreamChatService } from '@linktr.ee/messaging-core'
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
Logger,
|
|
4
|
+
MessagingUser,
|
|
5
|
+
StreamChatServiceConfig,
|
|
6
|
+
} from '@linktr.ee/messaging-core'
|
|
3
7
|
import { render, waitFor, act } from '@testing-library/react'
|
|
4
8
|
import React from 'react'
|
|
5
9
|
import type { StreamChat } from 'stream-chat'
|
|
6
10
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
7
11
|
|
|
12
|
+
import { useMessagingLogger } from '../logging'
|
|
13
|
+
|
|
8
14
|
import { MessagingProvider, useMessagingContext } from './MessagingProvider'
|
|
9
15
|
|
|
10
16
|
// Stub stream-chat-react's <Chat /> so we don't need a real, fully-wired
|
|
@@ -488,4 +494,124 @@ describe('MessagingProvider', () => {
|
|
|
488
494
|
expect(last.hasClient).toBe(false)
|
|
489
495
|
expect(last.error).toBeNull()
|
|
490
496
|
})
|
|
497
|
+
|
|
498
|
+
describe('logger seam', () => {
|
|
499
|
+
const ServiceProbe: React.FC<{
|
|
500
|
+
onService: (service: StreamChatService | null) => void
|
|
501
|
+
}> = ({ onService }) => {
|
|
502
|
+
const { service } = useMessagingContext()
|
|
503
|
+
React.useEffect(() => {
|
|
504
|
+
onService(service)
|
|
505
|
+
}, [service, onService])
|
|
506
|
+
return null
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const LoggerProbe: React.FC<{ onLogger: (logger: Logger) => void }> = ({
|
|
510
|
+
onLogger,
|
|
511
|
+
}) => {
|
|
512
|
+
const logger = useMessagingLogger()
|
|
513
|
+
React.useEffect(() => {
|
|
514
|
+
onLogger(logger)
|
|
515
|
+
}, [logger, onLogger])
|
|
516
|
+
return null
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// The service stores its resolved sink privately; read it to prove which
|
|
520
|
+
// logger the provider handed to the constructor.
|
|
521
|
+
const serviceLogger = (service: StreamChatService) =>
|
|
522
|
+
(service as unknown as { logger: Logger }).logger
|
|
523
|
+
|
|
524
|
+
const renderWithLogger = async (
|
|
525
|
+
props: Partial<React.ComponentProps<typeof MessagingProvider>> & {
|
|
526
|
+
serviceConfig: Omit<StreamChatServiceConfig, 'apiKey'>
|
|
527
|
+
}
|
|
528
|
+
) => {
|
|
529
|
+
setupServiceMock()
|
|
530
|
+
const services: (StreamChatService | null)[] = []
|
|
531
|
+
const loggers: Logger[] = []
|
|
532
|
+
|
|
533
|
+
render(
|
|
534
|
+
<MessagingProvider
|
|
535
|
+
apiKey="mock-api-key"
|
|
536
|
+
user={
|
|
537
|
+
{ type: 'guest', id: 'guest-1', name: 'Guest' } as MessagingUser
|
|
538
|
+
}
|
|
539
|
+
{...props}
|
|
540
|
+
>
|
|
541
|
+
<ServiceProbe onService={(s) => services.push(s)} />
|
|
542
|
+
<LoggerProbe onLogger={(l) => loggers.push(l)} />
|
|
543
|
+
</MessagingProvider>
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
await waitFor(() => expect(services.filter(Boolean).length).toBe(1))
|
|
547
|
+
return {
|
|
548
|
+
service: services.filter(Boolean).pop() as StreamChatService,
|
|
549
|
+
logger: loggers[loggers.length - 1],
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const baseServiceConfig: Omit<StreamChatServiceConfig, 'apiKey'> = {
|
|
554
|
+
fetchToken: async () => 'token',
|
|
555
|
+
createChannel: async () => ({ channelId: 'ch-1' }),
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
it('forwards the logger prop into the service and to the subtree', async () => {
|
|
559
|
+
const sink: Logger = { error: vi.fn() }
|
|
560
|
+
const { service, logger } = await renderWithLogger({
|
|
561
|
+
serviceConfig: baseServiceConfig,
|
|
562
|
+
logger: sink,
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
expect(serviceLogger(service).error).toBe(sink.error)
|
|
566
|
+
|
|
567
|
+
const error = new Error('boom')
|
|
568
|
+
logger.error?.('[MessagingShell] Failed:', error)
|
|
569
|
+
expect(sink.error).toHaveBeenCalledWith('[MessagingShell] Failed:', error)
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
it('resolves a partial prop sink before handing it to the service', async () => {
|
|
573
|
+
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
574
|
+
const sink: Logger = { error: vi.fn() }
|
|
575
|
+
const { service } = await renderWithLogger({
|
|
576
|
+
serviceConfig: baseServiceConfig,
|
|
577
|
+
logger: sink,
|
|
578
|
+
})
|
|
579
|
+
|
|
580
|
+
// The service swaps its own console defaults out wholesale, so an
|
|
581
|
+
// unresolved partial sink would silence the levels it omits.
|
|
582
|
+
serviceLogger(service).warn?.('[StreamChatService] Slow:', 1)
|
|
583
|
+
expect(consoleWarn).toHaveBeenCalledWith('[StreamChatService] Slow:', 1)
|
|
584
|
+
})
|
|
585
|
+
|
|
586
|
+
it('lets an explicit serviceConfig.logger win over the logger prop', async () => {
|
|
587
|
+
const configSink: Logger = { error: vi.fn() }
|
|
588
|
+
const propSink: Logger = { error: vi.fn() }
|
|
589
|
+
const { service } = await renderWithLogger({
|
|
590
|
+
serviceConfig: { ...baseServiceConfig, logger: configSink },
|
|
591
|
+
logger: propSink,
|
|
592
|
+
})
|
|
593
|
+
|
|
594
|
+
expect(serviceLogger(service)).toBe(configSink)
|
|
595
|
+
})
|
|
596
|
+
|
|
597
|
+
it('leaves the service on its console defaults when no logger is given', async () => {
|
|
598
|
+
const consoleError = vi
|
|
599
|
+
.spyOn(console, 'error')
|
|
600
|
+
.mockImplementation(() => {})
|
|
601
|
+
const { service, logger } = await renderWithLogger({
|
|
602
|
+
serviceConfig: baseServiceConfig,
|
|
603
|
+
})
|
|
604
|
+
|
|
605
|
+
// Not the react seam object: the service fell back to its own defaults.
|
|
606
|
+
expect(serviceLogger(service)).not.toBe(logger)
|
|
607
|
+
|
|
608
|
+
const error = new Error('boom')
|
|
609
|
+
logger.error?.('[MessagingShell] Failed:', error)
|
|
610
|
+
serviceLogger(service).error?.('[StreamChatService] Failed:', error)
|
|
611
|
+
expect(consoleError.mock.calls).toEqual([
|
|
612
|
+
['[MessagingShell] Failed:', error],
|
|
613
|
+
['[StreamChatService] Failed:', error],
|
|
614
|
+
])
|
|
615
|
+
})
|
|
616
|
+
})
|
|
491
617
|
})
|