@linktr.ee/messaging-react 3.3.4 → 3.3.5

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 (40) hide show
  1. package/dist/{Card-B7AF5uOB.js → Card-B9QrjooN.js} +3 -3
  2. package/dist/{Card-B7AF5uOB.js.map → Card-B9QrjooN.js.map} +1 -1
  3. package/dist/{Card-0BgubwgM.cjs → Card-BRRlz4kq.cjs} +2 -2
  4. package/dist/{Card-0BgubwgM.cjs.map → Card-BRRlz4kq.cjs.map} +1 -1
  5. package/dist/{Card-DLmUSU4A.cjs → Card-C-FCwjGa.cjs} +2 -2
  6. package/dist/{Card-DLmUSU4A.cjs.map → Card-C-FCwjGa.cjs.map} +1 -1
  7. package/dist/{Card-DchJqvYq.js → Card-CVZzYmYW.js} +2 -2
  8. package/dist/{Card-DchJqvYq.js.map → Card-CVZzYmYW.js.map} +1 -1
  9. package/dist/{Card-CvBbAoUo.cjs → Card-D_oLlfPw.cjs} +2 -2
  10. package/dist/{Card-CvBbAoUo.cjs.map → Card-D_oLlfPw.cjs.map} +1 -1
  11. package/dist/{Card-DmPpcrSU.js → Card-DzjYyrie.js} +2 -2
  12. package/dist/{Card-DmPpcrSU.js.map → Card-DzjYyrie.js.map} +1 -1
  13. package/dist/{LockedThumbnail-BQjA4HaB.js → LockedThumbnail-CJfXY_Ut.js} +2 -2
  14. package/dist/{LockedThumbnail-BQjA4HaB.js.map → LockedThumbnail-CJfXY_Ut.js.map} +1 -1
  15. package/dist/{LockedThumbnail-D9fSb4N-.cjs → LockedThumbnail-Cth1yWnH.cjs} +2 -2
  16. package/dist/{LockedThumbnail-D9fSb4N-.cjs.map → LockedThumbnail-Cth1yWnH.cjs.map} +1 -1
  17. package/dist/index-CBtOPvxW.cjs +2 -0
  18. package/dist/index-CBtOPvxW.cjs.map +1 -0
  19. package/dist/{index-BcHUpyyw.js → index-D7eRkXoG.js} +535 -537
  20. package/dist/index-D7eRkXoG.js.map +1 -0
  21. package/dist/index.cjs +1 -1
  22. package/dist/index.js +1 -1
  23. package/package.json +1 -1
  24. package/src/components/AttachmentCard/AttachmentCard.stories.tsx +104 -0
  25. package/src/components/ChannelActionsMenu/ChannelActionsMenu.test.tsx +33 -8
  26. package/src/components/ChannelList/CustomChannelPreview.stories.tsx +55 -47
  27. package/src/components/ChannelView.stories.tsx +8 -7
  28. package/src/components/CloseButton/CloseButton.stories.tsx +31 -0
  29. package/src/components/CustomDateSeparator/CustomDateSeparator.stories.tsx +33 -0
  30. package/src/components/CustomLinkPreviewList/CustomLinkPreviewCard.stories.tsx +63 -0
  31. package/src/components/CustomLinkPreviewList/CustomLinkPreviewCard.tsx +57 -0
  32. package/src/components/CustomLinkPreviewList/index.tsx +2 -54
  33. package/src/components/CustomMessage/CustomMessage.stories.tsx +3 -2
  34. package/src/components/CustomMessage/MessageTag.stories.tsx +4 -2
  35. package/src/components/MediaMessage/MediaMessage.stories.tsx +4 -2
  36. package/src/hooks/useChannelModerationActions.ts +32 -14
  37. package/src/stories/decorators/storyTime.ts +31 -0
  38. package/dist/index-BcHUpyyw.js.map +0 -1
  39. package/dist/index-DTZNltUC.cjs +0 -2
  40. package/dist/index-DTZNltUC.cjs.map +0 -1
@@ -0,0 +1,33 @@
1
+ import type { Meta, StoryFn } from '@storybook/react'
2
+ import React from 'react'
3
+
4
+ import { daysAgo, hoursAgo, now } from '../../stories/decorators/storyTime'
5
+
6
+ import { CustomDateSeparator } from '.'
7
+
8
+ type ComponentProps = React.ComponentProps<typeof CustomDateSeparator>
9
+
10
+ const meta: Meta<ComponentProps> = {
11
+ title: 'CustomDateSeparator',
12
+ component: CustomDateSeparator,
13
+ parameters: { layout: 'fullscreen' },
14
+ }
15
+ export default meta
16
+
17
+ const Template: StoryFn<ComponentProps> = (args) => (
18
+ <div className="w-[640px] bg-background-primary p-6">
19
+ <CustomDateSeparator {...args} />
20
+ </div>
21
+ )
22
+
23
+ export const Today: StoryFn<ComponentProps> = Template.bind({})
24
+ Today.args = { date: hoursAgo(2) }
25
+
26
+ export const Yesterday: StoryFn<ComponentProps> = Template.bind({})
27
+ Yesterday.args = { date: hoursAgo(30) }
28
+
29
+ export const LastWeek: StoryFn<ComponentProps> = Template.bind({})
30
+ LastWeek.args = { date: daysAgo(6) }
31
+
32
+ export const Unread: StoryFn<ComponentProps> = Template.bind({})
33
+ Unread.args = { date: now(), unread: true }
@@ -0,0 +1,63 @@
1
+ import type { Meta, StoryFn } from '@storybook/react'
2
+ import React from 'react'
3
+ import { LinkPreview, LinkPreviewStatus } from 'stream-chat'
4
+
5
+ import CustomLinkPreviewCard from './CustomLinkPreviewCard'
6
+
7
+ type ComponentProps = React.ComponentProps<typeof CustomLinkPreviewCard>
8
+
9
+ const meta: Meta<ComponentProps> = {
10
+ title: 'CustomLinkPreviewCard',
11
+ component: CustomLinkPreviewCard,
12
+ parameters: { layout: 'centered' },
13
+ argTypes: {
14
+ onDismiss: { action: 'dismissed' },
15
+ },
16
+ }
17
+ export default meta
18
+
19
+ const makePreview = (overrides: Partial<LinkPreview>): LinkPreview => ({
20
+ og_scrape_url: 'https://example.com',
21
+ status: LinkPreviewStatus.LOADED,
22
+ ...overrides,
23
+ })
24
+
25
+ const Template: StoryFn<ComponentProps> = (args) => (
26
+ <div className="p-12">
27
+ <CustomLinkPreviewCard {...args} />
28
+ </div>
29
+ )
30
+
31
+ export const WithTitleAndImage: StoryFn<ComponentProps> = Template.bind({})
32
+ WithTitleAndImage.args = {
33
+ link: makePreview({
34
+ og_scrape_url: 'https://linktr.ee/example',
35
+ title: 'Linktree — the link in your bio',
36
+ image_url: '/image-thumbnail.jpg',
37
+ }),
38
+ }
39
+
40
+ export const TitleOnly: StoryFn<ComponentProps> = Template.bind({})
41
+ TitleOnly.args = {
42
+ link: makePreview({
43
+ og_scrape_url: 'https://example.com/blog/post',
44
+ title: 'A long article title that explains why widgets are important',
45
+ }),
46
+ }
47
+
48
+ export const UrlOnly: StoryFn<ComponentProps> = Template.bind({})
49
+ UrlOnly.args = {
50
+ link: makePreview({
51
+ og_scrape_url: 'https://example.com/raw',
52
+ }),
53
+ }
54
+
55
+ export const VeryLongUrl: StoryFn<ComponentProps> = Template.bind({})
56
+ VeryLongUrl.args = {
57
+ link: makePreview({
58
+ og_scrape_url:
59
+ 'https://example.com/very/long/path/with/many/segments/and/query/parameters?param1=value1&param2=value2',
60
+ title: 'Page with an extremely long URL',
61
+ image_url: '/image-thumbnail.jpg',
62
+ }),
63
+ }
@@ -0,0 +1,57 @@
1
+ import { XIcon } from '@phosphor-icons/react'
2
+ import React from 'react'
3
+ import { LinkPreview } from 'stream-chat'
4
+
5
+ interface CustomLinkPreviewCardProps {
6
+ link: LinkPreview
7
+ onDismiss: (url: string) => void
8
+ }
9
+
10
+ const CustomLinkPreviewCard: React.FC<CustomLinkPreviewCardProps> = ({
11
+ link,
12
+ onDismiss,
13
+ }) => {
14
+ const { og_scrape_url, title, image_url } = link
15
+
16
+ const handleDismissLink = (e: React.MouseEvent) => {
17
+ e.preventDefault()
18
+ onDismiss(og_scrape_url)
19
+ }
20
+
21
+ return (
22
+ <a
23
+ href={og_scrape_url}
24
+ target="_blank"
25
+ rel="noopener noreferrer"
26
+ className="relative block w-[280px] max-w-full rounded-[24px] bg-[#121110] p-2 no-underline transition-opacity hover:opacity-90"
27
+ >
28
+ {image_url && (
29
+ <img
30
+ src={image_url}
31
+ alt={title || ''}
32
+ className="h-[180px] w-full rounded-[20px] object-cover"
33
+ />
34
+ )}
35
+ <button
36
+ type="button"
37
+ onClick={handleDismissLink}
38
+ className="absolute right-4 top-4 flex size-6 items-center justify-center rounded-full border border-white/40 bg-white/70 backdrop-blur-2xl focus-ring"
39
+ aria-label="Close link preview"
40
+ >
41
+ <XIcon className="size-4 text-black/90" />
42
+ </button>
43
+ <div className="p-2">
44
+ {title && (
45
+ <div className="text-[14px] font-medium leading-5 text-white">
46
+ {title}
47
+ </div>
48
+ )}
49
+ <div className="text-[12px] leading-4 text-white/55">
50
+ {og_scrape_url}
51
+ </div>
52
+ </div>
53
+ </a>
54
+ )
55
+ }
56
+
57
+ export default CustomLinkPreviewCard
@@ -1,12 +1,12 @@
1
- import { XIcon } from '@phosphor-icons/react'
2
1
  import React from 'react'
3
2
  import {
4
- LinkPreview,
5
3
  LinkPreviewsManager,
6
4
  LinkPreviewsManagerState,
7
5
  } from 'stream-chat'
8
6
  import { useMessageComposer, useStateStore } from 'stream-chat-react'
9
7
 
8
+ import CustomLinkPreviewCard from './CustomLinkPreviewCard'
9
+
10
10
  const linkPreviewsManagerStateSelector = (state: LinkPreviewsManagerState) => ({
11
11
  linkPreviews: Array.from(state.previews.values()).filter(
12
12
  (preview) =>
@@ -15,58 +15,6 @@ const linkPreviewsManagerStateSelector = (state: LinkPreviewsManagerState) => ({
15
15
  ),
16
16
  })
17
17
 
18
- interface CustomLinkPreviewCardProps {
19
- link: LinkPreview
20
- onDismiss: (url: string) => void
21
- }
22
-
23
- const CustomLinkPreviewCard: React.FC<CustomLinkPreviewCardProps> = ({
24
- link,
25
- onDismiss,
26
- }) => {
27
- const { og_scrape_url, title, image_url } = link
28
-
29
- const handleDismissLink = (e: React.MouseEvent) => {
30
- e.preventDefault()
31
- onDismiss(og_scrape_url)
32
- }
33
-
34
- return (
35
- <a
36
- href={og_scrape_url}
37
- target="_blank"
38
- rel="noopener noreferrer"
39
- className="relative block w-[280px] max-w-full rounded-[24px] bg-[#121110] p-2 no-underline transition-opacity hover:opacity-90"
40
- >
41
- {image_url && (
42
- <img
43
- src={image_url}
44
- alt={title || ''}
45
- className="h-[180px] w-full rounded-[20px] object-cover"
46
- />
47
- )}
48
- <button
49
- type="button"
50
- onClick={handleDismissLink}
51
- className="absolute right-4 top-4 flex size-6 items-center justify-center rounded-full border border-white/40 bg-white/70 backdrop-blur-2xl focus-ring"
52
- aria-label="Close link preview"
53
- >
54
- <XIcon className="size-4 text-black/90" />
55
- </button>
56
- <div className="p-2">
57
- {title && (
58
- <div className="text-[14px] font-medium leading-5 text-white">
59
- {title}
60
- </div>
61
- )}
62
- <div className="text-[12px] leading-4 text-white/55">
63
- {og_scrape_url}
64
- </div>
65
- </div>
66
- </a>
67
- )
68
- }
69
-
70
18
  export const CustomLinkPreviewList = () => {
71
19
  const { linkPreviewsManager } = useMessageComposer()
72
20
 
@@ -17,6 +17,7 @@ import {
17
17
  WithComponents,
18
18
  } from 'stream-chat-react'
19
19
 
20
+ import { minutesAgo } from '../../stories/decorators/storyTime'
20
21
  import {
21
22
  currentUserArgType,
22
23
  StoryUser,
@@ -46,8 +47,8 @@ const createMockChannel = async (
46
47
  const mockMessages = messages.map((msg, index) => ({
47
48
  ...msg,
48
49
  type: msg.type ?? ('regular' as const),
49
- created_at: new Date(Date.now() - 1000 * 60 * (messages.length - index)),
50
- updated_at: new Date(Date.now() - 1000 * 60 * (messages.length - index)),
50
+ created_at: minutesAgo(messages.length - index),
51
+ updated_at: minutesAgo(messages.length - index),
51
52
  html: `<p>${msg.text}</p>`,
52
53
  attachments: msg.attachments ?? [],
53
54
  latest_reactions: [],
@@ -2,6 +2,8 @@ import type { Meta, StoryFn } from '@storybook/react'
2
2
  import React from 'react'
3
3
  import { LocalMessage } from 'stream-chat'
4
4
 
5
+ import { now } from '../../stories/decorators/storyTime'
6
+
5
7
  import { MessageTag } from './MessageTag'
6
8
 
7
9
  type ComponentProps = React.ComponentProps<typeof MessageTag>
@@ -28,8 +30,8 @@ const createMockMessage = (options?: MockMessageOptions): LocalMessage =>
28
30
  id: 'msg-1',
29
31
  text: options?.text ?? 'Hello world',
30
32
  type: 'regular',
31
- created_at: new Date(),
32
- updated_at: new Date(),
33
+ created_at: now(),
34
+ updated_at: now(),
33
35
  metadata: options?.metadata,
34
36
  }) as LocalMessage
35
37
 
@@ -2,6 +2,8 @@ import type { Meta, StoryFn } from '@storybook/react'
2
2
  import React from 'react'
3
3
  import type { LocalMessage } from 'stream-chat'
4
4
 
5
+ import { now } from '../../stories/decorators/storyTime'
6
+
5
7
  import { MediaMessage } from '.'
6
8
 
7
9
  const meta: Meta<typeof MediaMessage> = {
@@ -20,8 +22,8 @@ const base = (overrides: Partial<LocalMessage> = {}): LocalMessage => {
20
22
  id: 'msg-1',
21
23
  text: '',
22
24
  type: 'regular',
23
- created_at: new Date(),
24
- updated_at: new Date(),
25
+ created_at: now(),
26
+ updated_at: now(),
25
27
  deleted_at: null,
26
28
  pinned_at: null,
27
29
  status: 'received',
@@ -75,33 +75,46 @@ export const useChannelModerationActions = ({
75
75
  logLabel = 'useChannelModerationActions',
76
76
  }: UseChannelModerationActionsParams): ChannelModerationActions => {
77
77
  const { service, debug } = useMessagingContext()
78
+ const participantId = participant?.user?.id
79
+ const willLookup = Boolean(
80
+ enabled && showBlockParticipant && service && participantId
81
+ )
82
+
78
83
  const [isParticipantBlocked, setIsParticipantBlocked] = useState(false)
79
- const [isCheckingBlockedStatus, setIsCheckingBlockedStatus] =
80
- useState(false)
84
+ // Tracks the participant + service the most recent lookup has completed
85
+ // for. Keying on both so a service swap (e.g. MessagingProvider rebuilding
86
+ // its StreamChatService when config/apiKey/debug changes) re-triggers the
87
+ // loading state for the same participant — otherwise the menu would
88
+ // briefly show actionable Block/Unblock against the new service with the
89
+ // old service's blocked result. Computing the flag at render time — rather
90
+ // than from a useState updated inside useEffect — also closes the brief
91
+ // window where the menu would render the regular (enabled) Block button
92
+ // before the effect flipped the disabled placeholder on.
93
+ const [resolvedFor, setResolvedFor] = useState<{
94
+ participantId: string
95
+ service: unknown
96
+ } | null>(null)
81
97
  const [isLeaving, setIsLeaving] = useState(false)
82
98
  const [isUpdatingBlockStatus, setIsUpdatingBlockStatus] = useState(false)
83
99
 
100
+ const isCheckingBlockedStatus =
101
+ willLookup &&
102
+ (resolvedFor?.participantId !== participantId ||
103
+ resolvedFor?.service !== service)
104
+
84
105
  // Resolve whether the participant is blocked whenever the participant or
85
106
  // surface visibility changes.
86
107
  useEffect(() => {
87
108
  // When the lookup is skipped (Block action hidden, surface disabled, or no
88
109
  // participant), clear any stale blocked state so a previous participant's
89
110
  // value can't leak into the next conversation.
90
- if (
91
- !enabled ||
92
- !showBlockParticipant ||
93
- !service ||
94
- !participant?.user?.id
95
- ) {
111
+ if (!willLookup || !service || !participantId) {
96
112
  setIsParticipantBlocked(false)
97
- setIsCheckingBlockedStatus(false)
113
+ setResolvedFor(null)
98
114
  return
99
115
  }
100
116
 
101
117
  let cancelled = false
102
- const participantId = participant.user.id
103
-
104
- setIsCheckingBlockedStatus(true)
105
118
 
106
119
  void (async () => {
107
120
  try {
@@ -117,7 +130,12 @@ export const useChannelModerationActions = ({
117
130
  console.error(`[${logLabel}] Failed to check blocked status:`, error)
118
131
  }
119
132
  } finally {
120
- if (!cancelled) setIsCheckingBlockedStatus(false)
133
+ // Mark the lookup as resolved regardless of success/failure so a
134
+ // rejected `getBlockedUsers()` doesn't leave the menu stuck in the
135
+ // disabled-spinner state. On failure the blocked flag stays at its
136
+ // default (false), matching the prior behavior — the user can attempt
137
+ // to block/unblock and the server rejects if the state is wrong.
138
+ if (!cancelled) setResolvedFor({ participantId, service })
121
139
  }
122
140
  })()
123
141
 
@@ -125,7 +143,7 @@ export const useChannelModerationActions = ({
125
143
  return () => {
126
144
  cancelled = true
127
145
  }
128
- }, [enabled, service, participant?.user?.id, showBlockParticipant, logLabel])
146
+ }, [willLookup, service, participantId, logLabel])
129
147
 
130
148
  const handleLeaveConversation = async () => {
131
149
  if (isLeaving) return
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Deterministic clock for Storybook stories.
3
+ *
4
+ * Stories that render relative timestamps ("3m ago", "yesterday") must use these
5
+ * helpers instead of `new Date()` / `Date.now()`, or visual diffs in Chromatic
6
+ * will drift on every run.
7
+ *
8
+ * FROZEN_NOW is the reference instant. Helpers below produce timestamps relative
9
+ * to it so the rendered output is identical regardless of when the build runs.
10
+ */
11
+
12
+ export const FROZEN_NOW = new Date('2026-01-15T12:00:00.000Z')
13
+
14
+ const MS_PER_SEC = 1000
15
+ const MS_PER_MIN = 60 * MS_PER_SEC
16
+ const MS_PER_HOUR = 60 * MS_PER_MIN
17
+ const MS_PER_DAY = 24 * MS_PER_HOUR
18
+
19
+ export const now = (): Date => new Date(FROZEN_NOW)
20
+
21
+ export const secondsAgo = (n: number): Date =>
22
+ new Date(FROZEN_NOW.getTime() - n * MS_PER_SEC)
23
+
24
+ export const minutesAgo = (n: number): Date =>
25
+ new Date(FROZEN_NOW.getTime() - n * MS_PER_MIN)
26
+
27
+ export const hoursAgo = (n: number): Date =>
28
+ new Date(FROZEN_NOW.getTime() - n * MS_PER_HOUR)
29
+
30
+ export const daysAgo = (n: number): Date =>
31
+ new Date(FROZEN_NOW.getTime() - n * MS_PER_DAY)