@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
@@ -483,330 +483,3 @@ 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
- })
@@ -1,6 +1,8 @@
1
- import type { Meta, StoryFn } from '@storybook/react'
1
+ import type { Decorator, Meta, StoryFn } from '@storybook/react'
2
2
  import React from 'react'
3
+ import { Chat, WithAudioPlayback } from 'stream-chat-react'
3
4
 
5
+ import { createMockStreamChatClient } from '../../../testing/createMockStreamChatClient'
4
6
  import {
5
7
  StoryGrid,
6
8
  StoryHeading,
@@ -14,27 +16,63 @@ import AudioAttachment from '.'
14
16
  const AUDIO_SRC =
15
17
  'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3'
16
18
 
19
+ /** Resolves nothing, so the metadata probe never fills the duration slot. */
20
+ const UNREACHABLE_SRC = 'https://cdn.example.invalid/unreachable.mp3'
21
+
22
+ /**
23
+ * The exact amplitudes Figma draws (`2754:17710` → `sound-wave`
24
+ * `…;2754:8065`), as bar heights on the 30px track normalised to `0..1`.
25
+ * Rendering the design's own values is what makes these stories a usable
26
+ * pixel reference.
27
+ */
28
+ const FIGMA_WAVEFORM = [
29
+ 4, 4, 4, 4, 24, 20, 16, 21, 8, 9, 13, 15, 16, 21, 24, 18, 14, 11, 11, 11, 11,
30
+ 11, 18, 24, 24, 30, 25, 21, 19, 16, 11, 12, 8, 4, 4,
31
+ ].map((height) => height / 30)
32
+
17
33
  const STACK_AUDIO: AudioItem[] = [
18
34
  {
19
35
  src: AUDIO_SRC,
20
36
  mimeType: 'audio/mpeg',
21
37
  filename: 'Take 1 — full mix.mp3',
38
+ durationSeconds: 12,
39
+ waveformData: FIGMA_WAVEFORM,
22
40
  },
23
41
  {
24
42
  src: AUDIO_SRC,
25
43
  mimeType: 'audio/mpeg',
26
44
  filename: 'Take 2 — vocals isolated.mp3',
45
+ durationSeconds: 95,
46
+ waveformData: [...FIGMA_WAVEFORM].reverse(),
27
47
  },
28
48
  {
29
49
  src: AUDIO_SRC,
30
50
  mimeType: 'audio/mpeg',
31
51
  filename: 'Take 3 — instrumental only.mp3',
52
+ durationSeconds: 3812,
32
53
  },
33
54
  ]
34
55
 
56
+ /**
57
+ * The card's playback comes from `stream-chat-react`'s pooled
58
+ * `AudioPlayer`, which `<Channel>` mounts in production. Mounting the
59
+ * pool directly gives the stories the same contract — real playback,
60
+ * one player at a time across the page — without needing a channel that
61
+ * can `watch()` against a backend. Without it every play / seek control
62
+ * renders disabled.
63
+ */
64
+ const withAudioPlayback: Decorator = (Story) => (
65
+ <Chat client={createMockStreamChatClient({ id: 'story-user' })}>
66
+ <WithAudioPlayback>
67
+ <Story />
68
+ </WithAudioPlayback>
69
+ </Chat>
70
+ )
71
+
35
72
  const meta: Meta = {
36
73
  title: 'MessageAttachment/Audio',
37
74
  parameters: { layout: 'fullscreen' },
75
+ decorators: [withAudioPlayback],
38
76
  }
39
77
  export default meta
40
78
 
@@ -43,16 +81,16 @@ const handleDismiss = () => {
43
81
  }
44
82
 
45
83
  /**
46
- * Native `<audio controls>` player inside a chat bubble. The browser
47
- * chrome provides play / scrub / volume / kebab-menu (which already
48
- * includes a download), so we don't render any extra controls.
49
- * Composer adds an inline dismiss `×` to the right of the player.
84
+ * The design's waveform player card: a 299×72 bubble holding a 32px
85
+ * play toggle, a 35-bar waveform with a progress fill and an invisible
86
+ * native seek control over it, the duration, and a 32px trailing action
87
+ * — Remove in the composer, Download once sent.
50
88
  */
51
89
  export const Single: StoryFn = () => (
52
90
  <StoryPage>
53
91
  <StoryHeading
54
92
  title="Audio attachment"
55
- description="Native browser audio chrome — play, scrub, volume, and the built-in download in the kebab menu. Composer renders an inline dismiss button next to the player."
93
+ description="Play to watch the fill advance bar by bar and the duration count up; click or drag the waveform to seek, or Tab to it and use the arrow keys. Starting a second card pauses the first — one player at a time across the whole thread."
56
94
  />
57
95
  <StoryGrid>
58
96
  <StoryRow
@@ -62,6 +100,8 @@ export const Single: StoryFn = () => (
62
100
  src={AUDIO_SRC}
63
101
  mimeType="audio/mpeg"
64
102
  filename="Morning meditation.mp3"
103
+ durationSeconds={12}
104
+ waveformData={FIGMA_WAVEFORM}
65
105
  onDismiss={handleDismiss}
66
106
  />
67
107
  }
@@ -70,6 +110,8 @@ export const Single: StoryFn = () => (
70
110
  src={AUDIO_SRC}
71
111
  mimeType="audio/mpeg"
72
112
  filename="Morning meditation.mp3"
113
+ durationSeconds={12}
114
+ waveformData={FIGMA_WAVEFORM}
73
115
  />
74
116
  }
75
117
  received={
@@ -77,6 +119,8 @@ export const Single: StoryFn = () => (
77
119
  src={AUDIO_SRC}
78
120
  mimeType="audio/mpeg"
79
121
  filename="Morning meditation.mp3"
122
+ durationSeconds={12}
123
+ waveformData={FIGMA_WAVEFORM}
80
124
  />
81
125
  }
82
126
  />
@@ -84,20 +128,28 @@ export const Single: StoryFn = () => (
84
128
  </StoryPage>
85
129
  )
86
130
 
87
- export const SingleWithText: StoryFn = () => (
131
+ /**
132
+ * Every case where no amplitudes can be produced — a decode failure, an
133
+ * unsupported codec, a file over the composer's decode ceiling, a clip
134
+ * re-staged from *Recently shared* (whose `File` is a zero-byte
135
+ * placeholder), or anything sent before `waveform_data` was persisted
136
+ * (legacy web, the mobile app, a visitor).
137
+ */
138
+ export const NoWaveform: StoryFn = () => (
88
139
  <StoryPage>
89
140
  <StoryHeading
90
- title="Audio with caption"
91
- description="Caption sits below the player, inside the same bubble."
141
+ title="Degraded states"
142
+ description="The waveform falls back to every bar at the design's 4px minimum, so the card keeps exactly the same silhouette and the progress fill still tracks playback. An unknown duration holds its slot as `--:--` until a metadata probe resolves it."
92
143
  />
93
144
  <StoryGrid>
94
145
  <StoryRow
95
- label="Caption"
146
+ label="No waveform"
96
147
  composer={
97
148
  <AudioAttachment.Composer
98
149
  src={AUDIO_SRC}
99
150
  mimeType="audio/mpeg"
100
- filename="Morning meditation.mp3"
151
+ filename="Voice note.m4a"
152
+ durationSeconds={12}
101
153
  onDismiss={handleDismiss}
102
154
  />
103
155
  }
@@ -105,87 +157,100 @@ export const SingleWithText: StoryFn = () => (
105
157
  <AudioAttachment.Sent
106
158
  src={AUDIO_SRC}
107
159
  mimeType="audio/mpeg"
108
- filename="Morning meditation.mp3"
109
- text="Recorded this morning before training"
160
+ filename="Voice note.m4a"
161
+ durationSeconds={12}
110
162
  />
111
163
  }
112
164
  received={
113
165
  <AudioAttachment.Received
114
166
  src={AUDIO_SRC}
115
167
  mimeType="audio/mpeg"
116
- filename="Morning meditation.mp3"
117
- text="Recorded this morning before training"
168
+ filename="Voice note.m4a"
169
+ durationSeconds={12}
118
170
  />
119
171
  }
120
172
  />
121
- </StoryGrid>
122
- </StoryPage>
123
- )
124
-
125
- /**
126
- * Stacked audio attachments — multiple clips inside one bubble. Each
127
- * clip renders its own native `<audio controls>` player, vertically
128
- * stacked with a hairline separator.
129
- *
130
- * Sent + Received only — the composer surface accepts a single
131
- * attachment at a time.
132
- */
133
- export const Stacked: StoryFn = () => (
134
- <StoryPage>
135
- <StoryHeading
136
- title="Stacked audio"
137
- description="Each clip plays independently with its own native controls. Composer column is intentionally blank — the composer accepts a single attachment at a time."
138
- />
139
- <StoryGrid>
140
173
  <StoryRow
141
- label="2 clips"
174
+ label="No waveform, no duration"
142
175
  composer={
143
176
  <AudioAttachment.Composer
144
- src={AUDIO_SRC}
145
- mimeType="audio/mpeg"
146
- filename="Morning meditation.mp3"
177
+ src={UNREACHABLE_SRC}
178
+ filename="Unreachable.mp3"
147
179
  onDismiss={handleDismiss}
148
180
  />
149
181
  }
150
- sent={<AudioAttachment.Sent items={STACK_AUDIO.slice(0, 2)} />}
151
- received={<AudioAttachment.Received items={STACK_AUDIO.slice(0, 2)} />}
182
+ sent={
183
+ <AudioAttachment.Sent
184
+ src={UNREACHABLE_SRC}
185
+ filename="Unreachable.mp3"
186
+ />
187
+ }
188
+ received={
189
+ <AudioAttachment.Received
190
+ src={UNREACHABLE_SRC}
191
+ filename="Unreachable.mp3"
192
+ />
193
+ }
152
194
  />
153
195
  <StoryRow
154
- label="3 clips"
196
+ label="Unplayable codec"
155
197
  composer={
156
198
  <AudioAttachment.Composer
157
199
  src={AUDIO_SRC}
158
- mimeType="audio/mpeg"
159
- filename="Morning meditation.mp3"
200
+ mimeType="audio/x-made-up"
201
+ filename="Master.aiff"
202
+ durationSeconds={12}
203
+ waveformData={FIGMA_WAVEFORM}
160
204
  onDismiss={handleDismiss}
161
205
  />
162
206
  }
163
- sent={<AudioAttachment.Sent items={STACK_AUDIO} />}
164
- received={<AudioAttachment.Received items={STACK_AUDIO} />}
207
+ sent={
208
+ <AudioAttachment.Sent
209
+ src={AUDIO_SRC}
210
+ mimeType="audio/x-made-up"
211
+ filename="Master.aiff"
212
+ durationSeconds={12}
213
+ waveformData={FIGMA_WAVEFORM}
214
+ />
215
+ }
216
+ received={
217
+ <AudioAttachment.Received
218
+ src={AUDIO_SRC}
219
+ mimeType="audio/x-made-up"
220
+ filename="Master.aiff"
221
+ durationSeconds={12}
222
+ waveformData={FIGMA_WAVEFORM}
223
+ />
224
+ }
165
225
  />
166
226
  </StoryGrid>
167
227
  </StoryPage>
168
228
  )
169
229
 
170
- /** Stacked audio with an accompanying caption.
171
- * Sent + Received only — see `Stacked` for the rationale. */
172
- export const StackedWithText: StoryFn = () => (
230
+ /**
231
+ * Stacked audio — multiple clips clustered into one bubble, as
232
+ * `StreamAttachmentMessage` does for consecutive same-kind attachments.
233
+ * Sent + Received only: the composer accepts one attachment at a time.
234
+ */
235
+ export const Stacked: StoryFn = () => (
173
236
  <StoryPage>
174
237
  <StoryHeading
175
- title="Stacked audio with caption"
176
- description="Caption sits inside the same bubble, below the player stack. Composer column is intentionally blank — the composer accepts a single attachment at a time."
238
+ title="Stacked audio"
239
+ description="Each clip is its own row and its own player, stacked with an 8px gap. Only one can play at a time. The third row has no waveform, so it shows the flat fallback beside two real ones."
177
240
  />
178
241
  <StoryGrid>
242
+ <StoryRow
243
+ label="2 clips"
244
+ sent={<AudioAttachment.Sent items={STACK_AUDIO.slice(0, 2)} />}
245
+ received={<AudioAttachment.Received items={STACK_AUDIO.slice(0, 2)} />}
246
+ />
247
+ <StoryRow
248
+ label="3 clips"
249
+ sent={<AudioAttachment.Sent items={STACK_AUDIO} />}
250
+ received={<AudioAttachment.Received items={STACK_AUDIO} />}
251
+ />
179
252
  <StoryRow
180
253
  label="3 clips + caption"
181
- composer={
182
- <AudioAttachment.Composer
183
- src={AUDIO_SRC}
184
- mimeType="audio/mpeg"
185
- filename="Morning meditation.mp3"
186
- onDismiss={handleDismiss}
187
- />
188
- }
189
254
  sent={
190
255
  <AudioAttachment.Sent
191
256
  items={STACK_AUDIO}
@@ -202,3 +267,45 @@ export const StackedWithText: StoryFn = () => (
202
267
  </StoryGrid>
203
268
  </StoryPage>
204
269
  )
270
+
271
+ /**
272
+ * The card keeps a uniform 20px radius in every grouping position — a
273
+ * 32px control tucked into a 4px flattened corner would collide with it
274
+ * — so a run of audio bubbles reads as a stack of identical cards.
275
+ */
276
+ export const Grouped: StoryFn = () => (
277
+ <StoryPage>
278
+ <StoryHeading
279
+ title="Same-author run"
280
+ description="`groupPosition` still serialises onto the bubble, but the audio card opts out of the corner flattening the text bubbles apply."
281
+ />
282
+ <StoryGrid>
283
+ {(['first', 'middle', 'end'] as const).map((groupPosition) => (
284
+ <StoryRow
285
+ key={groupPosition}
286
+ label={groupPosition}
287
+ sent={
288
+ <AudioAttachment.Sent
289
+ src={AUDIO_SRC}
290
+ mimeType="audio/mpeg"
291
+ filename="Morning meditation.mp3"
292
+ durationSeconds={12}
293
+ waveformData={FIGMA_WAVEFORM}
294
+ groupPosition={groupPosition}
295
+ />
296
+ }
297
+ received={
298
+ <AudioAttachment.Received
299
+ src={AUDIO_SRC}
300
+ mimeType="audio/mpeg"
301
+ filename="Morning meditation.mp3"
302
+ durationSeconds={12}
303
+ waveformData={FIGMA_WAVEFORM}
304
+ groupPosition={groupPosition}
305
+ />
306
+ }
307
+ />
308
+ ))}
309
+ </StoryGrid>
310
+ </StoryPage>
311
+ )