@linktr.ee/messaging-react 4.2.0-rc-1787083791 → 4.2.0-rc-1787181113

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 (71) hide show
  1. package/dist/{Card-B4lMv-0o.js → Card-BOudQBd6.js} +2 -2
  2. package/dist/{Card-B4lMv-0o.js.map → Card-BOudQBd6.js.map} +1 -1
  3. package/dist/{Card-DWL5I62S.cjs → Card-k2959zNX.cjs} +2 -2
  4. package/dist/{Card-DWL5I62S.cjs.map → Card-k2959zNX.cjs.map} +1 -1
  5. package/dist/assets/index.css +1 -1
  6. package/dist/{index-C93LTGLc.js → index-6RdyZh6F.js} +1809 -1912
  7. package/dist/index-6RdyZh6F.js.map +1 -0
  8. package/dist/index-BcU18EjV.cjs +3 -0
  9. package/dist/index-BcU18EjV.cjs.map +1 -0
  10. package/dist/index.cjs +1 -1
  11. package/dist/index.d.ts +78 -59
  12. package/dist/index.js +30 -26
  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 +2 -2
  18. package/src/components/AttachmentCard/MediaPlayer.test.tsx +346 -0
  19. package/src/components/AttachmentCard/Thumbnail.test.tsx +215 -0
  20. package/src/components/AttachmentCard/index.test.tsx +160 -0
  21. package/src/components/Avatar/Avatar.test.tsx +126 -0
  22. package/src/components/Avatar/getAvatarEmoji.test.ts +39 -0
  23. package/src/components/ChannelList/ChannelListContext.test.tsx +87 -0
  24. package/src/components/ChannelList/CustomChannelPreview.test.tsx +410 -3
  25. package/src/components/ChannelList/index.test.tsx +132 -1
  26. package/src/components/CustomMessage/CustomMessage.stories.tsx +6 -314
  27. package/src/components/CustomMessage/CustomMessage.test.tsx +56 -2
  28. package/src/components/CustomMessage/Locked.stories.tsx +108 -0
  29. package/src/components/CustomMessage/MessageTag.stories.tsx +17 -53
  30. package/src/components/CustomMessage/MessageTag.test.tsx +7 -40
  31. package/src/components/CustomMessage/MessageTag.tsx +7 -39
  32. package/src/components/CustomMessage/PaidMessage.stories.tsx +42 -0
  33. package/src/components/CustomMessage/SentMessageDeliveryStatus.test.tsx +8 -7
  34. package/src/components/CustomMessage/SentMessageDeliveryStatus.tsx +2 -1
  35. package/src/components/CustomMessage/StreamAttachmentMessage.test.tsx +0 -33
  36. package/src/components/CustomMessage/StreamAttachmentMessage.tsx +0 -6
  37. package/src/components/CustomMessage/Tip.stories.tsx +48 -0
  38. package/src/components/CustomMessage/TipMessage/TipMessage.stories.tsx +51 -0
  39. package/src/components/CustomMessage/TipMessage/TipMessage.test.tsx +65 -0
  40. package/src/components/CustomMessage/TipMessage/index.tsx +27 -0
  41. package/src/components/CustomMessage/index.tsx +22 -19
  42. package/src/components/FaqList/FaqList.test.tsx +204 -0
  43. package/src/components/LinkAttachment/LinkAttachment.test.tsx +327 -0
  44. package/src/components/LinkAttachment/components/_shared/BubbleTail.test.tsx +76 -0
  45. package/src/components/LinkAttachment/components/_shared/CardBody.test.tsx +248 -0
  46. package/src/components/LinkAttachment/components/_shared/CardCta.test.tsx +123 -0
  47. package/src/components/LinkAttachment/components/_shared/CardShell.test.tsx +193 -0
  48. package/src/components/LinkAttachment/components/_shared/CardThumbnail.test.tsx +242 -0
  49. package/src/components/LinkAttachment/components/_shared/hex.test.ts +37 -0
  50. package/src/components/LinkAttachment/components/_shared/normalizeExternalHref.test.ts +72 -0
  51. package/src/components/LinkAttachment/components/_shared/useChinPalette.test.ts +162 -0
  52. package/src/components/MessageAttachment/Audio/AudioAttachment.stories.tsx +58 -165
  53. package/src/components/MessageAttachment/Audio/index.tsx +77 -71
  54. package/src/components/MessageAttachment/MessageAttachment.test.tsx +74 -176
  55. package/src/components/MessageAttachment/index.tsx +9 -25
  56. package/src/components/MessageAttachment/types.ts +9 -19
  57. package/src/components/MessagingShell/MessagingShell.test.tsx +20 -0
  58. package/src/components/MessagingShell/index.tsx +2 -0
  59. package/src/index.ts +4 -0
  60. package/src/logging/index.test.tsx +136 -0
  61. package/src/logging/index.tsx +48 -0
  62. package/src/providers/MessagingProvider.test.tsx +113 -1
  63. package/src/providers/MessagingProvider.tsx +25 -15
  64. package/src/stories/customMessageStoryTemplate.tsx +199 -0
  65. package/src/styles.css +10 -121
  66. package/src/testing/createMockMessagingClient.ts +2 -2
  67. package/src/types.ts +9 -0
  68. package/dist/index-B-9LkWep.cjs +0 -3
  69. package/dist/index-B-9LkWep.cjs.map +0 -1
  70. package/dist/index-C93LTGLc.js.map +0 -1
  71. package/src/components/MessageAttachment/Audio/WaveformAudioRow.tsx +0 -335
@@ -0,0 +1,204 @@
1
+ import React from 'react'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+
4
+ import { fireEvent, renderWithProviders, screen } from '../../test/utils'
5
+ import { getAvatarEmoji } from '../Avatar/getAvatarEmoji'
6
+
7
+ import { FaqListItem } from './FaqListItem'
8
+
9
+ import { FaqList } from './index'
10
+
11
+ const faqs = [
12
+ {
13
+ id: 'second',
14
+ question: 'What payment methods do you accept?',
15
+ answer: 'Cards and bank transfers.',
16
+ enabled: true,
17
+ order: 2,
18
+ },
19
+ {
20
+ id: 'disabled',
21
+ question: 'What is your phone number?',
22
+ answer: 'This FAQ is disabled.',
23
+ enabled: false,
24
+ order: 0,
25
+ },
26
+ {
27
+ id: 'first',
28
+ question: 'How do I get started?',
29
+ answer: 'Create an account.',
30
+ enabled: true,
31
+ order: 1,
32
+ },
33
+ ]
34
+
35
+ const baseItemClasses = [
36
+ 'w-full',
37
+ 'text-center',
38
+ 'p-4',
39
+ 'rounded-xl',
40
+ 'text-charcoal',
41
+ 'font-medium',
42
+ 'transition-colors',
43
+ 'focus-ring',
44
+ ]
45
+
46
+ describe('FaqListItem', () => {
47
+ it('renders its question and defaults to an enabled button', () => {
48
+ renderWithProviders(
49
+ <FaqListItem question="How do I get started?" onClick={vi.fn()} />
50
+ )
51
+
52
+ const button = screen.getByRole('button', {
53
+ name: 'How do I get started?',
54
+ })
55
+ expect(button).toBeEnabled()
56
+ expect(button.className.split(/\s+/)).toEqual([
57
+ ...baseItemClasses,
58
+ 'hover:brightness-95',
59
+ 'active:brightness-90',
60
+ ])
61
+ })
62
+
63
+ it('renders the FAQ surface color', () => {
64
+ renderWithProviders(
65
+ <FaqListItem question="How do I get started?" onClick={vi.fn()} />
66
+ )
67
+
68
+ expect(
69
+ screen.getByRole('button', { name: 'How do I get started?' })
70
+ ).toHaveStyle({ backgroundColor: '#E6E5E3' })
71
+ })
72
+
73
+ it('uses loading-only classes and blocks clicks while loading', () => {
74
+ const onClick = vi.fn()
75
+ renderWithProviders(
76
+ <FaqListItem question="How do I get started?" onClick={onClick} loading />
77
+ )
78
+
79
+ const button = screen.getByRole('button', {
80
+ name: 'How do I get started?',
81
+ })
82
+ expect(button).toBeDisabled()
83
+ expect(button.className.split(/\s+/)).toEqual([
84
+ ...baseItemClasses,
85
+ 'opacity-50',
86
+ 'cursor-not-allowed',
87
+ ])
88
+
89
+ fireEvent.click(button)
90
+ expect(onClick).not.toHaveBeenCalled()
91
+ })
92
+
93
+ it('calls the click handler once when enabled', () => {
94
+ const onClick = vi.fn()
95
+ renderWithProviders(
96
+ <FaqListItem question="How do I get started?" onClick={onClick} />
97
+ )
98
+
99
+ fireEvent.click(screen.getByRole('button'))
100
+ expect(onClick).toHaveBeenCalledTimes(1)
101
+ })
102
+ })
103
+
104
+ describe('FaqList', () => {
105
+ it('returns nothing when no FAQ is enabled', () => {
106
+ const { container } = renderWithProviders(
107
+ <FaqList
108
+ faqs={faqs.map((faq) => ({ ...faq, enabled: false }))}
109
+ onFaqClick={vi.fn()}
110
+ />
111
+ )
112
+
113
+ expect(container.firstChild).toBeNull()
114
+ })
115
+
116
+ it('renders enabled FAQs in ascending order and filters disabled FAQs', () => {
117
+ renderWithProviders(<FaqList faqs={faqs} onFaqClick={vi.fn()} />)
118
+
119
+ expect(
120
+ screen.getByRole('button', { name: 'How do I get started?' })
121
+ .parentElement
122
+ ).toHaveStyle({ backgroundColor: '#F1F0EE' })
123
+ expect(
124
+ screen.getAllByRole('button').map((button) => button.textContent)
125
+ ).toEqual(['How do I get started?', 'What payment methods do you accept?'])
126
+ expect(
127
+ screen.queryByRole('button', { name: 'What is your phone number?' })
128
+ ).toBeNull()
129
+ })
130
+
131
+ it('renders the header only when headerText is provided', () => {
132
+ const { container, rerender } = renderWithProviders(
133
+ <FaqList faqs={faqs} onFaqClick={vi.fn()} />
134
+ )
135
+ expect(screen.queryByText('Frequently asked questions')).toBeNull()
136
+ expect(container.querySelector('p')).toBeNull()
137
+
138
+ rerender(
139
+ <FaqList
140
+ faqs={faqs}
141
+ onFaqClick={vi.fn()}
142
+ headerText="Frequently asked questions"
143
+ />
144
+ )
145
+ expect(screen.getByText('Frequently asked questions')).toBeInTheDocument()
146
+ })
147
+
148
+ it('passes className to the list root', () => {
149
+ const { container } = renderWithProviders(
150
+ <FaqList
151
+ faqs={faqs}
152
+ onFaqClick={vi.fn()}
153
+ className="faq-list-custom-class"
154
+ />
155
+ )
156
+
157
+ expect(container.firstElementChild).toHaveClass('faq-list-custom-class')
158
+ })
159
+
160
+ it('renders an avatar only when avatar data is provided', () => {
161
+ const { rerender } = renderWithProviders(
162
+ <FaqList faqs={faqs} onFaqClick={vi.fn()} />
163
+ )
164
+ expect(screen.queryAllByTestId('avatar-ring')).toHaveLength(0)
165
+
166
+ rerender(
167
+ <FaqList faqs={faqs} onFaqClick={vi.fn()} avatarName="Brie Nala" />
168
+ )
169
+ expect(screen.queryAllByTestId('avatar-ring')).toHaveLength(1)
170
+ expect(screen.getByText(getAvatarEmoji('Brie Nala'))).toBeInTheDocument()
171
+
172
+ rerender(
173
+ <FaqList
174
+ faqs={faqs}
175
+ onFaqClick={vi.fn()}
176
+ avatarImage="https://cdn.example.com/avatar.jpg"
177
+ />
178
+ )
179
+ expect(screen.queryAllByTestId('avatar-ring')).toHaveLength(1)
180
+ })
181
+
182
+ it('passes the clicked FAQ id and marks only its matching row as loading', () => {
183
+ const onFaqClick = vi.fn()
184
+ renderWithProviders(
185
+ <FaqList faqs={faqs} onFaqClick={onFaqClick} loadingFaqId="second" />
186
+ )
187
+
188
+ const firstButton = screen.getByRole('button', {
189
+ name: 'How do I get started?',
190
+ })
191
+ const secondButton = screen.getByRole('button', {
192
+ name: 'What payment methods do you accept?',
193
+ })
194
+ expect(firstButton).toBeEnabled()
195
+ expect(secondButton).toBeDisabled()
196
+ expect(firstButton).not.toHaveClass('opacity-50', 'cursor-not-allowed')
197
+ expect(secondButton).toHaveClass('opacity-50', 'cursor-not-allowed')
198
+
199
+ fireEvent.click(firstButton)
200
+ fireEvent.click(secondButton)
201
+ expect(onFaqClick).toHaveBeenCalledTimes(1)
202
+ expect(onFaqClick).toHaveBeenCalledWith('first')
203
+ })
204
+ })
@@ -483,3 +483,330 @@ describe('LinkAttachment accent chin (MES-1349, MES-1388)', () => {
483
483
  )
484
484
  })
485
485
  })
486
+
487
+ const AUDIO = {
488
+ mimeType: 'audio/mpeg',
489
+ sourceUrl: 'https://cdn.example.com/clip.mp3',
490
+ }
491
+ const VIDEO = {
492
+ mimeType: 'video/mp4',
493
+ sourceUrl: 'https://cdn.example.com/clip.mp4',
494
+ }
495
+ const HERO = 'https://cdn.example.com/yoga.jpg'
496
+
497
+ // Sent / Composer cards carry no test hook of their own; the shell is the
498
+ // element that owns the card chrome classes.
499
+ const shellOf = (container: HTMLElement) =>
500
+ container.querySelector('.messaging-link-card') as HTMLElement
501
+
502
+ describe('LinkAttachment.Composer chrome', () => {
503
+ it('anchors the dismiss control to the hero on a featured card', () => {
504
+ const onDismiss = vi.fn()
505
+ const { container } = renderWithProviders(
506
+ <LinkAttachment.Composer
507
+ title="Brie’s FAQ"
508
+ thumbnailUrl={HERO}
509
+ onDismiss={onDismiss}
510
+ />
511
+ )
512
+ const dismiss = screen.getByLabelText('Dismiss attachment')
513
+ expect(dismiss.parentElement?.className).toContain('absolute right-3 top-3')
514
+ // Sits inside the hero region, not the shell corner.
515
+ expect(container.querySelector('div.flex-1')?.contains(dismiss)).toBe(true)
516
+ // 44x44 touch target without growing the 24px circle.
517
+ expect(dismiss.className).toContain('before:-inset-[10px]')
518
+ fireEvent.click(dismiss)
519
+ expect(onDismiss).toHaveBeenCalledTimes(1)
520
+ })
521
+
522
+ it('moves the dismiss control to the shell corner on a classic card', () => {
523
+ const { container } = renderWithProviders(
524
+ <LinkAttachment.Composer
525
+ title="Brie’s FAQ"
526
+ thumbnailUrl={HERO}
527
+ layout="classic"
528
+ onDismiss={() => {}}
529
+ />
530
+ )
531
+ expect(container.querySelector('img')).toBeNull()
532
+ const shell = shellOf(container)
533
+ expect(shell.contains(screen.getByLabelText('Dismiss attachment'))).toBe(
534
+ true
535
+ )
536
+ // Classic cards size to their content.
537
+ expect(shell.className).not.toContain('h-[250px]')
538
+ expect(shell.className).toContain('block')
539
+ })
540
+
541
+ it('defaults to the featured layout with its hero and fixed box', () => {
542
+ const { container } = renderWithProviders(
543
+ <LinkAttachment.Composer title="Brie’s FAQ" thumbnailUrl={HERO} />
544
+ )
545
+ expect(container.querySelector('img')?.getAttribute('src')).toBe(HERO)
546
+ expect(shellOf(container).className).toContain('h-[250px]')
547
+ })
548
+
549
+ it('collapses an audio composer card to the player plus an inline dismiss', () => {
550
+ const { container } = renderWithProviders(
551
+ <LinkAttachment.Composer
552
+ title="My track"
553
+ url="tr.ee/briemix"
554
+ {...AUDIO}
555
+ onDismiss={() => {}}
556
+ />
557
+ )
558
+ const shell = shellOf(container)
559
+ expect(shell.className).toContain(AUDIO_BG_CLASS)
560
+ expect(container.querySelector('audio')).not.toBeNull()
561
+ // No chin on this path, so the URL line goes with it.
562
+ expect(screen.queryByTestId('link-attachment-url')).toBeNull()
563
+ const dismiss = screen.getByLabelText('Dismiss attachment')
564
+ // Inline sibling of the native controls: no overlay halo on top of them.
565
+ expect(dismiss.className).not.toContain('before:-inset-[10px]')
566
+ expect(dismiss.parentElement?.className).not.toContain('absolute')
567
+ })
568
+
569
+ it('lets the consumer name the dismiss control', () => {
570
+ renderWithProviders(
571
+ <LinkAttachment.Composer
572
+ title="Brie’s FAQ"
573
+ onDismiss={() => {}}
574
+ dismissLabel="Remove link"
575
+ />
576
+ )
577
+ expect(screen.getByLabelText('Remove link')).toBeInTheDocument()
578
+ expect(screen.queryByLabelText('Dismiss attachment')).toBeNull()
579
+ })
580
+
581
+ it('renders the edit affordance only when a handler is given', () => {
582
+ const onEditClick = vi.fn()
583
+ renderWithProviders(
584
+ <LinkAttachment.Composer title="Brie’s FAQ" onEditClick={onEditClick} />
585
+ )
586
+ fireEvent.click(screen.getByLabelText('Edit attachment'))
587
+ expect(onEditClick).toHaveBeenCalledTimes(1)
588
+ expect(screen.queryByLabelText('Dismiss attachment')).toBeNull()
589
+ })
590
+
591
+ it('renders no affordances when the composer passes no handlers', () => {
592
+ renderWithProviders(<LinkAttachment.Composer title="Brie’s FAQ" />)
593
+ expect(screen.queryByRole('button')).toBeNull()
594
+ })
595
+ })
596
+
597
+ describe('LinkAttachment interactivity contract', () => {
598
+ it('keeps the Received shell non-interactive around playable media', () => {
599
+ const onClick = vi.fn()
600
+ renderWithProviders(
601
+ <LinkAttachment.Received
602
+ title="Clip"
603
+ url="tr.ee/briemix"
604
+ thumbnailUrl={HERO}
605
+ {...VIDEO}
606
+ onClick={onClick}
607
+ />
608
+ )
609
+ // The native controls own clicks, so the shell is neither anchor nor button.
610
+ const root = screen.getByTestId('link-attachment')
611
+ expect(root.tagName).toBe('DIV')
612
+ fireEvent.click(root)
613
+ expect(onClick).not.toHaveBeenCalled()
614
+ })
615
+
616
+ it('keeps the Received shell non-interactive when a CTA owns the click', () => {
617
+ renderWithProviders(
618
+ <LinkAttachment.Received
619
+ title="FAQ"
620
+ url="tr.ee/briemix"
621
+ cta={{ label: 'View FAQs' }}
622
+ />
623
+ )
624
+ expect(screen.getByTestId('link-attachment').tagName).toBe('DIV')
625
+ })
626
+
627
+ it('treats a whitespace-only Received url as no url at all', () => {
628
+ renderWithProviders(
629
+ <LinkAttachment.Received title="Brie’s FAQ" url=" " />
630
+ )
631
+ const root = screen.getByTestId('link-attachment')
632
+ expect(root.tagName).toBe('DIV')
633
+ expect(root.hasAttribute('href')).toBe(false)
634
+ expect(screen.queryByTestId('link-attachment-url')).toBeNull()
635
+ })
636
+
637
+ it('names the Received preview button after the title, then falls back', () => {
638
+ const { unmount } = renderWithProviders(
639
+ <LinkAttachment.Received
640
+ title="Featured image"
641
+ thumbnailUrl={HERO}
642
+ onClick={() => {}}
643
+ />
644
+ )
645
+ expect(screen.getByTestId('link-attachment')).toHaveAttribute(
646
+ 'aria-label',
647
+ 'Featured image'
648
+ )
649
+ unmount()
650
+
651
+ renderWithProviders(
652
+ <LinkAttachment.Received thumbnailUrl={HERO} onClick={() => {}} />
653
+ )
654
+ expect(screen.getByTestId('link-attachment')).toHaveAttribute(
655
+ 'aria-label',
656
+ 'Open attachment preview'
657
+ )
658
+ })
659
+
660
+ it('ignores the Sent onClick when there is nothing to open', () => {
661
+ const onClick = vi.fn()
662
+ const { container } = renderWithProviders(
663
+ <LinkAttachment.Sent title="My Playlist" onClick={onClick} />
664
+ )
665
+ const shell = shellOf(container)
666
+ expect(shell.tagName).toBe('DIV')
667
+ fireEvent.click(shell)
668
+ expect(onClick).not.toHaveBeenCalled()
669
+ })
670
+
671
+ it('ignores the Sent onClick around playable media', () => {
672
+ const onClick = vi.fn()
673
+ const { container } = renderWithProviders(
674
+ <LinkAttachment.Sent
675
+ title="Clip"
676
+ url="tr.ee/briemix"
677
+ thumbnailUrl={HERO}
678
+ {...VIDEO}
679
+ onClick={onClick}
680
+ />
681
+ )
682
+ const shell = shellOf(container)
683
+ expect(shell.tagName).toBe('DIV')
684
+ fireEvent.click(shell)
685
+ expect(onClick).not.toHaveBeenCalled()
686
+ })
687
+ })
688
+
689
+ describe('LinkAttachment bubble tail grouping', () => {
690
+ it.each([
691
+ ['single', true],
692
+ ['end', true],
693
+ ['first', false],
694
+ ['middle', false],
695
+ ] as const)(
696
+ 'paints the Received tail for groupPosition %s: %s',
697
+ (groupPosition, expected) => {
698
+ renderWithProviders(
699
+ <LinkAttachment.Received
700
+ title="Brie’s FAQ"
701
+ url="tr.ee/briemix"
702
+ groupPosition={groupPosition}
703
+ />
704
+ )
705
+ expect(screen.queryByTestId('link-card-tail') != null).toBe(expected)
706
+ }
707
+ )
708
+
709
+ it.each([
710
+ ['single', true],
711
+ ['end', true],
712
+ ['first', false],
713
+ ] as const)(
714
+ 'paints the Sent tail for groupPosition %s: %s',
715
+ (groupPosition, expected) => {
716
+ renderWithProviders(
717
+ <LinkAttachment.Sent
718
+ title="Brie’s FAQ"
719
+ url="tr.ee/briemix"
720
+ groupPosition={groupPosition}
721
+ />
722
+ )
723
+ expect(screen.queryByTestId('link-card-tail') != null).toBe(expected)
724
+ }
725
+ )
726
+
727
+ it('defaults both chat cards to the tail-bearing single position', () => {
728
+ const { unmount } = renderWithProviders(
729
+ <LinkAttachment.Received title="Brie’s FAQ" url="tr.ee/briemix" />
730
+ )
731
+ expect(screen.getByTestId('link-card-tail')).toHaveAttribute(
732
+ 'data-variant',
733
+ 'light'
734
+ )
735
+ unmount()
736
+
737
+ renderWithProviders(
738
+ <LinkAttachment.Sent title="Brie’s FAQ" url="tr.ee/briemix" />
739
+ )
740
+ expect(screen.getByTestId('link-card-tail')).toHaveAttribute(
741
+ 'data-variant',
742
+ 'dark'
743
+ )
744
+ })
745
+
746
+ it('gives the audio player card no tail — it is not a bubble', () => {
747
+ const { unmount } = renderWithProviders(
748
+ <LinkAttachment.Sent title="My track" {...AUDIO} groupPosition="end" />
749
+ )
750
+ expect(screen.queryByTestId('link-card-tail')).toBeNull()
751
+ unmount()
752
+
753
+ renderWithProviders(
754
+ <LinkAttachment.Received
755
+ title="My track"
756
+ {...AUDIO}
757
+ groupPosition="end"
758
+ />
759
+ )
760
+ expect(screen.queryByTestId('link-card-tail')).toBeNull()
761
+ })
762
+ })
763
+
764
+ describe('LinkAttachment layout and surface', () => {
765
+ it.each([
766
+ ['Sent', LinkAttachment.Sent],
767
+ ['Received', LinkAttachment.Received],
768
+ ] as const)(
769
+ 'drops the %s hero and the fixed box for classic',
770
+ (_side, Card) => {
771
+ const { container } = renderWithProviders(
772
+ <Card
773
+ title="Brie’s FAQ"
774
+ url="tr.ee/briemix"
775
+ thumbnailUrl={HERO}
776
+ layout="classic"
777
+ />
778
+ )
779
+ expect(container.querySelector('img')).toBeNull()
780
+ const shell = shellOf(container)
781
+ expect(shell.className).not.toContain('h-[250px]')
782
+ expect(shell.className).toContain('block')
783
+ }
784
+ )
785
+
786
+ it.each([
787
+ ['Sent', LinkAttachment.Sent],
788
+ ['Received', LinkAttachment.Received],
789
+ ] as const)('locks the featured %s card to the 250px box', (_side, Card) => {
790
+ const { container } = renderWithProviders(
791
+ <Card title="Brie’s FAQ" url="tr.ee/briemix" thumbnailUrl={HERO} />
792
+ )
793
+ expect(container.querySelector('img')?.getAttribute('src')).toBe(HERO)
794
+ expect(shellOf(container).className).toContain('h-[250px]')
795
+ })
796
+
797
+ it.each([
798
+ ['Sent', LinkAttachment.Sent],
799
+ ['Received', LinkAttachment.Received],
800
+ ] as const)(
801
+ 'sizes the %s audio card to its content on the audio surface',
802
+ (_side, Card) => {
803
+ const { container } = renderWithProviders(
804
+ <Card title="My track" url="tr.ee/briemix" {...AUDIO} />
805
+ )
806
+ const shell = shellOf(container)
807
+ expect(shell.className).toContain(AUDIO_BG_CLASS)
808
+ expect(shell.className).not.toContain('h-[250px]')
809
+ expect(container.querySelector('audio')).not.toBeNull()
810
+ }
811
+ )
812
+ })
@@ -0,0 +1,76 @@
1
+ import React from 'react'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import { renderWithProviders, screen } from '../../../../test/utils'
5
+
6
+ import BubbleTail from './BubbleTail'
7
+
8
+ describe('BubbleTail', () => {
9
+ it('renders the card untouched when the tail is not visible', () => {
10
+ const { container } = renderWithProviders(
11
+ <BubbleTail side="sender" visible={false}>
12
+ <span>card</span>
13
+ </BubbleTail>
14
+ )
15
+ expect(screen.queryByTestId('link-card-tail')).not.toBeInTheDocument()
16
+ expect(container.firstElementChild?.tagName).toBe('SPAN')
17
+ })
18
+
19
+ it('wraps the card so the tail can hang outside its box', () => {
20
+ renderWithProviders(
21
+ <BubbleTail side="sender" visible>
22
+ <span>card</span>
23
+ </BubbleTail>
24
+ )
25
+ const tail = screen.getByTestId('link-card-tail')
26
+ expect(tail.parentElement?.className).toBe('relative isolate w-fit')
27
+ expect(tail.getAttribute('aria-hidden')).toBe('true')
28
+ // The tail follows the card so it paints over the card's bottom edge.
29
+ expect(screen.getByText('card').nextElementSibling).toBe(tail)
30
+ })
31
+
32
+ it('sits on the dark stop for the sender and stays unmirrored', () => {
33
+ renderWithProviders(
34
+ <BubbleTail side="sender" visible>
35
+ <span>card</span>
36
+ </BubbleTail>
37
+ )
38
+ const tail = screen.getByTestId('link-card-tail')
39
+ expect(tail.getAttribute('data-variant')).toBe('dark')
40
+ expect(tail.className).toContain('messaging-link-card-tail')
41
+ expect(tail.className).not.toContain('messaging-link-card-tail--received')
42
+ })
43
+
44
+ it('mirrors to the pale stop for the receiver', () => {
45
+ renderWithProviders(
46
+ <BubbleTail side="receiver" visible>
47
+ <span>card</span>
48
+ </BubbleTail>
49
+ )
50
+ const tail = screen.getByTestId('link-card-tail')
51
+ expect(tail.getAttribute('data-variant')).toBe('light')
52
+ expect(tail.className).toContain('messaging-link-card-tail--received')
53
+ })
54
+
55
+ it('publishes the accent so the tail derives the card surface in CSS', () => {
56
+ renderWithProviders(
57
+ <BubbleTail side="sender" visible accentHex="#7008E7">
58
+ <span>card</span>
59
+ </BubbleTail>
60
+ )
61
+ expect(
62
+ screen.getByTestId('link-card-tail').style.getPropertyValue('--accent')
63
+ ).toBe('#7008E7')
64
+ })
65
+
66
+ it('falls back to the plain bubble fill without an accent', () => {
67
+ renderWithProviders(
68
+ <BubbleTail side="sender" visible>
69
+ <span>card</span>
70
+ </BubbleTail>
71
+ )
72
+ expect(
73
+ screen.getByTestId('link-card-tail').style.getPropertyValue('--accent')
74
+ ).toBe('')
75
+ })
76
+ })