@linktr.ee/messaging-react 1.0.0 → 1.0.2

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 (42) hide show
  1. package/dist/index.js +149 -140
  2. package/dist/index.js.map +1 -1
  3. package/package.json +5 -4
  4. package/src/components/ActionButton/ActionButton.stories.tsx +46 -0
  5. package/src/components/ActionButton/ActionButton.test.tsx +112 -0
  6. package/src/components/ActionButton/index.tsx +33 -0
  7. package/src/components/Avatar/Avatar.stories.tsx +144 -0
  8. package/src/components/Avatar/avatarColors.ts +35 -0
  9. package/src/components/Avatar/index.tsx +64 -0
  10. package/src/components/ChannelList/ChannelList.stories.tsx +48 -0
  11. package/src/components/ChannelList/CustomChannelPreview.stories.tsx +303 -0
  12. package/src/components/ChannelList/CustomChannelPreview.tsx +121 -0
  13. package/src/components/ChannelList/index.tsx +129 -0
  14. package/src/components/ChannelView.tsx +422 -0
  15. package/src/components/CloseButton/index.tsx +16 -0
  16. package/src/components/IconButton/IconButton.stories.tsx +40 -0
  17. package/src/components/IconButton/index.tsx +32 -0
  18. package/src/components/Loading/Loading.stories.tsx +24 -0
  19. package/src/components/Loading/index.tsx +50 -0
  20. package/src/components/MessagingShell/EmptyState.stories.tsx +38 -0
  21. package/src/components/MessagingShell/EmptyState.tsx +58 -0
  22. package/src/components/MessagingShell/ErrorState.stories.tsx +42 -0
  23. package/src/components/MessagingShell/ErrorState.tsx +33 -0
  24. package/src/components/MessagingShell/LoadingState.stories.tsx +26 -0
  25. package/src/components/MessagingShell/LoadingState.tsx +15 -0
  26. package/src/components/MessagingShell/index.tsx +298 -0
  27. package/src/components/ParticipantPicker/ParticipantItem.stories.tsx +188 -0
  28. package/src/components/ParticipantPicker/ParticipantItem.tsx +59 -0
  29. package/src/components/ParticipantPicker/ParticipantPicker.stories.tsx +54 -0
  30. package/src/components/ParticipantPicker/ParticipantPicker.tsx +196 -0
  31. package/src/components/ParticipantPicker/index.tsx +234 -0
  32. package/src/components/SearchInput/SearchInput.stories.tsx +33 -0
  33. package/src/components/SearchInput/SearchInput.test.tsx +108 -0
  34. package/src/components/SearchInput/index.tsx +50 -0
  35. package/src/hooks/useMessaging.ts +9 -0
  36. package/src/hooks/useParticipants.ts +92 -0
  37. package/src/index.ts +26 -0
  38. package/src/providers/MessagingProvider.tsx +282 -0
  39. package/src/stories/mocks.tsx +157 -0
  40. package/src/test/setup.ts +30 -0
  41. package/src/test/utils.tsx +23 -0
  42. package/src/types.ts +113 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linktr.ee/messaging-react",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "React messaging components built on messaging-core for web applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -14,7 +14,8 @@
14
14
  },
15
15
  "license": "UNLICENSED",
16
16
  "files": [
17
- "dist"
17
+ "dist",
18
+ "src"
18
19
  ],
19
20
  "scripts": {
20
21
  "build": "vite build",
@@ -60,7 +61,7 @@
60
61
  "react-dom": "^18.3.1",
61
62
  "storybook": "^8.5.0",
62
63
  "stream-chat": "^9.22.1",
63
- "tailwindcss": "3.2.7",
64
+ "tailwindcss": "^3.4.17",
64
65
  "tailwindcss-animate": "^1.0.7",
65
66
  "typescript": "5.9.2",
66
67
  "vite": "^6.0.7",
@@ -74,4 +75,4 @@
74
75
  "stream-chat": "^9.22.1",
75
76
  "stream-chat-react": "^13.9.0"
76
77
  }
77
- }
78
+ }
@@ -0,0 +1,46 @@
1
+ import type { Meta, StoryFn } from '@storybook/react'
2
+ import ActionButton from '.'
3
+ import React from 'react'
4
+
5
+
6
+ type ComponentProps = React.ComponentProps<typeof ActionButton>
7
+
8
+ const meta: Meta<ComponentProps> = {
9
+ title: 'ActionButton',
10
+ component: ActionButton,
11
+ parameters: {
12
+ layout: 'centered',
13
+ },
14
+ argTypes: {
15
+ onClick: { action: 'clicked' },
16
+ },
17
+ }
18
+ export default meta
19
+
20
+ const Template: StoryFn<ComponentProps> = (args) => {
21
+ return (
22
+ <div className="p-12">
23
+ <ActionButton {...args} />
24
+ </div>
25
+ )
26
+ }
27
+
28
+ export const Default: StoryFn<ComponentProps> = Template.bind({})
29
+ Default.args = {
30
+ children: 'Default Button',
31
+ variant: 'default',
32
+ }
33
+
34
+ export const Danger: StoryFn<ComponentProps> = Template.bind({})
35
+ Danger.args = {
36
+ children: 'Delete',
37
+ variant: 'danger',
38
+ }
39
+
40
+ export const Disabled: StoryFn<ComponentProps> = Template.bind({})
41
+ Disabled.args = {
42
+ children: 'Disabled Button',
43
+ variant: 'default',
44
+ disabled: true,
45
+ }
46
+
@@ -0,0 +1,112 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { renderWithProviders, screen, userEvent } from '../../test/utils';
3
+ import ActionButton from '.';
4
+
5
+ describe('ActionButton', () => {
6
+ describe('Rendering', () => {
7
+ it('renders with children text', () => {
8
+ renderWithProviders(<ActionButton>Click me</ActionButton>);
9
+ expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
10
+ });
11
+
12
+ it('renders with default variant', () => {
13
+ renderWithProviders(<ActionButton>Default</ActionButton>);
14
+ const button = screen.getByRole('button');
15
+ expect(button).toHaveClass('text-charcoal');
16
+ });
17
+
18
+ it('renders with danger variant', () => {
19
+ renderWithProviders(<ActionButton variant="danger">Delete</ActionButton>);
20
+ const button = screen.getByRole('button');
21
+ expect(button).toHaveClass('text-danger');
22
+ });
23
+
24
+ it('has full width by default', () => {
25
+ renderWithProviders(<ActionButton>Full Width</ActionButton>);
26
+ const button = screen.getByRole('button');
27
+ expect(button).toHaveClass('w-full');
28
+ });
29
+ });
30
+
31
+ describe('Disabled State', () => {
32
+ it('is disabled when disabled prop is true', () => {
33
+ renderWithProviders(<ActionButton disabled>Disabled</ActionButton>);
34
+ const button = screen.getByRole('button');
35
+ expect(button).toBeDisabled();
36
+ });
37
+
38
+ it('does not trigger onClick when disabled', async () => {
39
+ const handleClick = vi.fn();
40
+ const user = userEvent.setup();
41
+
42
+ renderWithProviders(
43
+ <ActionButton disabled onClick={handleClick}>
44
+ Disabled
45
+ </ActionButton>
46
+ );
47
+
48
+ const button = screen.getByRole('button');
49
+ await user.click(button);
50
+
51
+ expect(handleClick).not.toHaveBeenCalled();
52
+ });
53
+
54
+ it('applies disabled styles', () => {
55
+ renderWithProviders(<ActionButton disabled>Disabled</ActionButton>);
56
+ const button = screen.getByRole('button');
57
+ expect(button).toHaveClass('disabled:opacity-60');
58
+ expect(button).toHaveClass('disabled:cursor-not-allowed');
59
+ });
60
+ });
61
+
62
+ describe('Click Handling', () => {
63
+ it('calls onClick when clicked', async () => {
64
+ const handleClick = vi.fn();
65
+ const user = userEvent.setup();
66
+
67
+ renderWithProviders(<ActionButton onClick={handleClick}>Click me</ActionButton>);
68
+
69
+ const button = screen.getByRole('button');
70
+ await user.click(button);
71
+
72
+ expect(handleClick).toHaveBeenCalledTimes(1);
73
+ });
74
+ });
75
+
76
+ describe('Type Attribute', () => {
77
+ it('has button type by default', () => {
78
+ renderWithProviders(<ActionButton>Default Type</ActionButton>);
79
+ const button = screen.getByRole('button');
80
+ expect(button).toHaveAttribute('type', 'button');
81
+ });
82
+
83
+ it('can have submit type', () => {
84
+ renderWithProviders(<ActionButton type="submit">Submit</ActionButton>);
85
+ const button = screen.getByRole('button');
86
+ expect(button).toHaveAttribute('type', 'submit');
87
+ });
88
+ });
89
+
90
+ describe('Accessibility', () => {
91
+ it('is keyboard accessible', async () => {
92
+ const handleClick = vi.fn();
93
+ const user = userEvent.setup();
94
+
95
+ renderWithProviders(<ActionButton onClick={handleClick}>Press me</ActionButton>);
96
+
97
+ const button = screen.getByRole('button');
98
+ button.focus();
99
+ await user.keyboard('{Enter}');
100
+
101
+ expect(handleClick).toHaveBeenCalledTimes(1);
102
+ });
103
+
104
+ it('can be focused', () => {
105
+ renderWithProviders(<ActionButton>Focus me</ActionButton>);
106
+ const button = screen.getByRole('button');
107
+ button.focus();
108
+ expect(button).toHaveFocus();
109
+ });
110
+ });
111
+ });
112
+
@@ -0,0 +1,33 @@
1
+ import classNames from "classnames";
2
+ import React from "react";
3
+
4
+ interface ActionButtonProps
5
+ extends React.ButtonHTMLAttributes<HTMLButtonElement> {
6
+ variant?: "default" | "danger";
7
+ }
8
+
9
+ const ActionButton = ({
10
+ variant = "default",
11
+ className,
12
+ children,
13
+ ...rest
14
+ }: ActionButtonProps) => {
15
+ const isDanger = variant === "danger";
16
+ return (
17
+ <button
18
+ type="button"
19
+ className={classNames(
20
+ "flex w-full items-center gap-3 rounded-lg px-4 py-3 text-left text-sm transition-colors focus-ring disabled:cursor-not-allowed disabled:opacity-60",
21
+ isDanger
22
+ ? "text-danger hover:bg-danger/50"
23
+ : "text-charcoal hover:bg-sand",
24
+ className,
25
+ )}
26
+ {...rest}
27
+ >
28
+ {children}
29
+ </button>
30
+ );
31
+ };
32
+
33
+ export default ActionButton;
@@ -0,0 +1,144 @@
1
+ import type { Meta, StoryFn } from '@storybook/react'
2
+ import { Avatar } from './index'
3
+ import React from 'react'
4
+
5
+ type ComponentProps = React.ComponentProps<typeof Avatar>
6
+
7
+ const meta: Meta<ComponentProps> = {
8
+ title: 'Avatar',
9
+ component: Avatar,
10
+ parameters: {
11
+ layout: 'centered'
12
+ }
13
+ }
14
+ export default meta
15
+
16
+ const Template: StoryFn<ComponentProps> = (args) => {
17
+ return (
18
+ <div className="p-12">
19
+ <Avatar {...args} />
20
+ </div>
21
+ )
22
+ }
23
+
24
+ export const Default: StoryFn<ComponentProps> = Template.bind({})
25
+ Default.args = {
26
+ id: 'user-1',
27
+ name: 'Alice Johnson',
28
+ image: 'https://i.pravatar.cc/150?img=1',
29
+ }
30
+
31
+ export const WithoutImage: StoryFn<ComponentProps> = Template.bind({})
32
+ WithoutImage.args = {
33
+ id: 'user-2',
34
+ name: 'Bob Smith',
35
+ }
36
+
37
+ export const SmallSize: StoryFn<ComponentProps> = Template.bind({})
38
+ SmallSize.args = {
39
+ id: 'user-3',
40
+ name: 'Carol Williams',
41
+ size: 20,
42
+ }
43
+
44
+ export const MediumSize: StoryFn<ComponentProps> = Template.bind({})
45
+ MediumSize.args = {
46
+ id: 'user-4',
47
+ name: 'David Brown',
48
+ size: 32,
49
+ }
50
+
51
+ export const DefaultSize: StoryFn<ComponentProps> = Template.bind({})
52
+ DefaultSize.args = {
53
+ id: 'user-5',
54
+ name: 'Emma Davis',
55
+ size: 40,
56
+ }
57
+
58
+ export const LargeSize: StoryFn<ComponentProps> = Template.bind({})
59
+ LargeSize.args = {
60
+ id: 'user-6',
61
+ name: 'Frank Miller',
62
+ size: 56,
63
+ }
64
+
65
+ export const ExtraLargeSize: StoryFn<ComponentProps> = Template.bind({})
66
+ ExtraLargeSize.args = {
67
+ id: 'user-7',
68
+ name: 'Grace Lee',
69
+ size: 80,
70
+ }
71
+
72
+ export const LongName: StoryFn<ComponentProps> = Template.bind({})
73
+ LongName.args = {
74
+ id: 'user-8',
75
+ name: 'Alexander Christopher Wellington-Montgomery III',
76
+ }
77
+
78
+ export const DifferentColors: StoryFn = () => {
79
+ const users = [
80
+ { id: 'user-1', name: 'Alice Anderson' },
81
+ { id: 'user-2', name: 'Bob Brown' },
82
+ { id: 'user-3', name: 'Carol Chen' },
83
+ { id: 'user-4', name: 'David Davis' },
84
+ { id: 'user-5', name: 'Emma Evans' },
85
+ { id: 'user-6', name: 'Frank Foster' },
86
+ { id: 'user-7', name: 'Grace Green' },
87
+ { id: 'user-8', name: 'Henry Harris' },
88
+ ]
89
+
90
+ return (
91
+ <div className="p-12">
92
+ <div className="flex gap-4 flex-wrap">
93
+ {users.map((user) => (
94
+ <div key={user.id} className="flex flex-col items-center gap-2">
95
+ <Avatar id={user.id} name={user.name} />
96
+ <span className="text-xs text-stone">{user.name}</span>
97
+ </div>
98
+ ))}
99
+ </div>
100
+ </div>
101
+ )
102
+ }
103
+
104
+ export const MixedAvatars: StoryFn = () => {
105
+ const users = [
106
+ { id: 'user-1', name: 'Alice Anderson', image: 'https://i.pravatar.cc/150?img=1' },
107
+ { id: 'user-2', name: 'Bob Brown' },
108
+ { id: 'user-3', name: 'Carol Chen', image: 'https://i.pravatar.cc/150?img=3' },
109
+ { id: 'user-4', name: 'David Davis' },
110
+ { id: 'user-5', name: 'Emma Evans', image: 'https://i.pravatar.cc/150?img=5' },
111
+ { id: 'user-6', name: 'Frank Foster' },
112
+ ]
113
+
114
+ return (
115
+ <div className="p-12">
116
+ <div className="flex gap-4 flex-wrap items-center">
117
+ {users.map((user) => (
118
+ <div key={user.id} className="flex flex-col items-center gap-2">
119
+ <Avatar id={user.id} name={user.name} image={user.image} />
120
+ <span className="text-xs text-stone">{user.name}</span>
121
+ </div>
122
+ ))}
123
+ </div>
124
+ </div>
125
+ )
126
+ }
127
+
128
+ export const VariousSizes: StoryFn = () => {
129
+ const sizes = [20, 32, 40, 56, 80]
130
+
131
+ return (
132
+ <div className="p-12">
133
+ <div className="flex gap-6 items-end">
134
+ {sizes.map((size) => (
135
+ <div key={size} className="flex flex-col items-center gap-2">
136
+ <Avatar id="user-consistent" name="Sarah Johnson" size={size} />
137
+ <span className="text-xs text-stone">{size}px</span>
138
+ </div>
139
+ ))}
140
+ </div>
141
+ </div>
142
+ )
143
+ }
144
+
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Generate avatar background and text colors based on a string
3
+ * Uses the ID to deterministically select from design system colors
4
+ */
5
+ export function getAvatarColors(id: string): {
6
+ bgColor: string
7
+ textColor: string
8
+ } {
9
+ // Map of color combinations from Linktree design system
10
+ // Selected for good contrast at 20% opacity
11
+ const colorPairs = [
12
+ { bgColor: 'bg-primary/20', textColor: 'text-primary' }, // #8129D9 - purple
13
+ { bgColor: 'bg-forest/20', textColor: 'text-forest' }, // #254f1a - dark green
14
+ { bgColor: 'bg-iris/20', textColor: 'text-iris' }, // #061492 - dark blue
15
+ { bgColor: 'bg-shade/20', textColor: 'text-shade' }, // #1e2330 - dark blue-gray
16
+ { bgColor: 'bg-dahlia/20', textColor: 'text-dahlia' }, // #502274 - dark purple
17
+ { bgColor: 'bg-orchid/20', textColor: 'text-orchid' }, // #d717e7 - magenta
18
+ { bgColor: 'bg-currant/20', textColor: 'text-currant' }, // #780016 - dark red
19
+ { bgColor: 'bg-apple/20', textColor: 'text-apple' }, // #c41500 - red
20
+ { bgColor: 'bg-rose/20', textColor: 'text-rose' }, // #fc3e4b - pink
21
+ { bgColor: 'bg-root/20', textColor: 'text-root' }, // #4c2e05 - brown
22
+ { bgColor: 'bg-poppy/20', textColor: 'text-poppy' }, // #ff6c02 - orange
23
+ { bgColor: 'bg-moss/20', textColor: 'text-moss' }, // #70764d - olive green
24
+ ]
25
+
26
+ // Simple hash function to get consistent index
27
+ let hash = 0
28
+ for (let i = 0; i < id.length; i++) {
29
+ hash = (hash << 5) - hash + id.charCodeAt(i)
30
+ hash = hash & hash // Convert to 32bit integer
31
+ }
32
+
33
+ const index = Math.abs(hash) % colorPairs.length
34
+ return colorPairs[index]
35
+ }
@@ -0,0 +1,64 @@
1
+ import React from 'react';
2
+ import classNames from 'classnames';
3
+ import { getAvatarColors } from './avatarColors';
4
+
5
+ export interface AvatarProps {
6
+ id: string;
7
+ name: string;
8
+ image?: string;
9
+ size?: number;
10
+ className?: string;
11
+ }
12
+
13
+ /**
14
+ * Avatar component that displays a user image or colored initial fallback
15
+ */
16
+ export const Avatar: React.FC<AvatarProps> = ({
17
+ id,
18
+ name,
19
+ image,
20
+ size = 40,
21
+ className,
22
+ }) => {
23
+ const { bgColor, textColor } = getAvatarColors(id);
24
+ const initial = name.charAt(0).toUpperCase();
25
+
26
+ // Determine font size based on avatar size
27
+ const getFontSizeClass = () => {
28
+ if (size < 32) return 'text-xs';
29
+ if (size < 56) return 'text-sm';
30
+ return 'text-lg';
31
+ };
32
+
33
+ const fontSizeClass = getFontSizeClass();
34
+
35
+ return (
36
+ <div
37
+ className={classNames(
38
+ 'flex-shrink-0 overflow-hidden rounded-full',
39
+ className
40
+ )}
41
+ style={{ width: `${size}px`, height: `${size}px` }}
42
+ >
43
+ {image ? (
44
+ <img
45
+ src={image}
46
+ alt=""
47
+ className="h-full w-full object-cover aspect-square"
48
+ />
49
+ ) : (
50
+ <div
51
+ className={classNames(
52
+ 'flex h-full w-full items-center justify-center font-semibold',
53
+ bgColor,
54
+ textColor,
55
+ fontSizeClass
56
+ )}
57
+ >
58
+ {initial}
59
+ </div>
60
+ )}
61
+ </div>
62
+ );
63
+ };
64
+
@@ -0,0 +1,48 @@
1
+ import type { Meta, StoryFn } from '@storybook/react'
2
+ import { ChannelList } from '.'
3
+ import { MockChatProvider } from '../../stories/mocks'
4
+
5
+ import React from 'react'
6
+
7
+ type ComponentProps = React.ComponentProps<typeof ChannelList>
8
+
9
+ const meta: Meta<ComponentProps> = {
10
+ title: 'ChannelList',
11
+ component: ChannelList,
12
+ parameters: {
13
+ layout: 'fullscreen',
14
+ },
15
+ decorators: [
16
+ (Story) => (
17
+ <MockChatProvider>
18
+ <Story />
19
+ </MockChatProvider>
20
+ ),
21
+ ],
22
+ }
23
+ export default meta
24
+
25
+ const Template: StoryFn<ComponentProps> = (args) => {
26
+ return (
27
+ <div className="h-screen w-full md:w-[360px] bg-chalk">
28
+ <ChannelList {...args} />
29
+ </div>
30
+ )
31
+ }
32
+
33
+ export const Default: StoryFn<ComponentProps> = Template.bind({})
34
+ Default.args = {
35
+ onChannelSelect: (channel) => console.log('Channel selected:', channel.id),
36
+ participantLabel: 'participants',
37
+ }
38
+
39
+ export const WithStartConversation: StoryFn<ComponentProps> = Template.bind({})
40
+ WithStartConversation.args = {
41
+ onChannelSelect: (channel) => console.log('Channel selected:', channel.id),
42
+ showStartConversation: true,
43
+ onStartConversation: () => console.log('Start conversation clicked'),
44
+ participantLabel: 'followers',
45
+ }
46
+
47
+
48
+