@linktr.ee/messaging-react 2.1.0 → 2.2.0-rc-1778753733

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 (58) hide show
  1. package/dist/{Card-CsJvUF_b.js → Card-BdTueeyk.js} +2 -2
  2. package/dist/{Card-CsJvUF_b.js.map → Card-BdTueeyk.js.map} +1 -1
  3. package/dist/{Card-DlMSDSdm.js → Card-ChR37pLZ.js} +2 -2
  4. package/dist/{Card-DlMSDSdm.js.map → Card-ChR37pLZ.js.map} +1 -1
  5. package/dist/{Card-CFFNq49v.js → Card-EKxCn56j.js} +3 -3
  6. package/dist/{Card-CFFNq49v.js.map → Card-EKxCn56j.js.map} +1 -1
  7. package/dist/{LockedThumbnail-DpJx169C.js → LockedThumbnail-B16qP3eH.js} +2 -2
  8. package/dist/{LockedThumbnail-DpJx169C.js.map → LockedThumbnail-B16qP3eH.js.map} +1 -1
  9. package/dist/index-Dn7BC9xK.js +4748 -0
  10. package/dist/index-Dn7BC9xK.js.map +1 -0
  11. package/dist/index.d.ts +591 -25
  12. package/dist/index.js +24 -19
  13. package/package.json +1 -1
  14. package/src/components/CustomMessage/MessageAttachmentConversations.stories.tsx +841 -0
  15. package/src/components/LinkAttachment/LinkAttachment.stories.tsx +7 -92
  16. package/src/components/LinkAttachment/LinkAttachment.test.tsx +69 -0
  17. package/src/components/LinkAttachment/components/Received/Card.tsx +10 -30
  18. package/src/components/LinkAttachment/components/_shared/CardShell.tsx +5 -1
  19. package/src/components/LinkAttachment/index.tsx +24 -50
  20. package/src/components/LinkAttachment/types.ts +12 -5
  21. package/src/components/MessageAttachment/Audio/AudioAttachment.stories.tsx +203 -0
  22. package/src/components/MessageAttachment/Audio/index.tsx +189 -0
  23. package/src/components/MessageAttachment/File/FileAttachment.stories.tsx +352 -0
  24. package/src/components/MessageAttachment/File/index.tsx +240 -0
  25. package/src/components/MessageAttachment/Image/ImageAttachment.stories.tsx +288 -0
  26. package/src/components/MessageAttachment/Image/index.tsx +257 -0
  27. package/src/components/MessageAttachment/MessageAttachment.test.tsx +783 -0
  28. package/src/components/MessageAttachment/Pdf/PdfAttachment.stories.tsx +292 -0
  29. package/src/components/MessageAttachment/Pdf/index.tsx +228 -0
  30. package/src/components/MessageAttachment/Video/VideoAttachment.stories.tsx +272 -0
  31. package/src/components/MessageAttachment/Video/index.tsx +281 -0
  32. package/src/components/MessageAttachment/_shared/Bubble.tsx +173 -0
  33. package/src/components/MessageAttachment/_shared/CompactDocumentRow.tsx +152 -0
  34. package/src/components/MessageAttachment/_shared/DismissButton.tsx +39 -0
  35. package/src/components/MessageAttachment/_shared/DownloadAction.tsx +175 -0
  36. package/src/components/MessageAttachment/_shared/ImageViewer.tsx +314 -0
  37. package/src/components/MessageAttachment/_shared/MediaStackGrid.tsx +139 -0
  38. package/src/components/MessageAttachment/_shared/PdfViewer.tsx +100 -0
  39. package/src/components/MessageAttachment/_shared/VideoViewer.tsx +171 -0
  40. package/src/components/MessageAttachment/_shared/ViewerShell.tsx +159 -0
  41. package/src/components/MessageAttachment/_shared/fileMeta.test.ts +82 -0
  42. package/src/components/MessageAttachment/_shared/fileMeta.ts +95 -0
  43. package/src/components/MessageAttachment/_shared/triggerDownload.ts +54 -0
  44. package/src/components/MessageAttachment/_shared/useViewer.ts +53 -0
  45. package/src/components/MessageAttachment/index.tsx +149 -0
  46. package/src/components/MessageAttachment/stories/StoryTable.tsx +72 -0
  47. package/src/components/MessageAttachment/types.ts +178 -0
  48. package/src/index.ts +32 -0
  49. package/dist/Card-D32U6KfZ.js +0 -85
  50. package/dist/Card-D32U6KfZ.js.map +0 -1
  51. package/dist/Card-DlSSJPip.js +0 -60
  52. package/dist/Card-DlSSJPip.js.map +0 -1
  53. package/dist/Card-zGbhRBwv.js +0 -48
  54. package/dist/Card-zGbhRBwv.js.map +0 -1
  55. package/dist/CardThumbnail-DTBuRQHF.js +0 -239
  56. package/dist/CardThumbnail-DTBuRQHF.js.map +0 -1
  57. package/dist/index-DfcRe-Hj.js +0 -3103
  58. package/dist/index-DfcRe-Hj.js.map +0 -1
@@ -0,0 +1,272 @@
1
+ import type { Meta, StoryFn } from '@storybook/react'
2
+ import React from 'react'
3
+
4
+ import {
5
+ StoryGrid,
6
+ StoryHeading,
7
+ StoryPage,
8
+ StoryRow,
9
+ } from '../stories/StoryTable'
10
+ import type { VideoItem } from '../types'
11
+
12
+ import VideoAttachment from '.'
13
+
14
+ // Sourced from `.storybook/public/` so we share the same assets used by
15
+ // the LockedAttachment stories.
16
+ const VIDEO_SRC = '/video-source.mp4'
17
+ const VIDEO_POSTER = '/video-thumbnail.jpg'
18
+
19
+ const STACK_VIDEOS: VideoItem[] = [
20
+ {
21
+ src: VIDEO_SRC,
22
+ poster: VIDEO_POSTER,
23
+ mimeType: 'video/mp4',
24
+ },
25
+ {
26
+ src: VIDEO_SRC,
27
+ poster: VIDEO_POSTER,
28
+ mimeType: 'video/mp4',
29
+ },
30
+ {
31
+ src: VIDEO_SRC,
32
+ poster: VIDEO_POSTER,
33
+ mimeType: 'video/mp4',
34
+ },
35
+ {
36
+ src: VIDEO_SRC,
37
+ poster: VIDEO_POSTER,
38
+ mimeType: 'video/mp4',
39
+ },
40
+ ]
41
+
42
+ const meta: Meta = {
43
+ title: 'MessageAttachment/Video',
44
+ parameters: { layout: 'fullscreen' },
45
+ }
46
+ export default meta
47
+
48
+ const handleDismiss = () => {
49
+ // eslint-disable-next-line no-console
50
+ console.log('Dismissed video attachment')
51
+ }
52
+
53
+ /**
54
+ * Single video — the bubble shows the poster with a play overlay.
55
+ * Clicking opens the full-viewport `VideoViewer` with native controls
56
+ * (play / pause / seek / fullscreen / volume) and a download action.
57
+ */
58
+ export const Single: StoryFn = () => (
59
+ <StoryPage>
60
+ <StoryHeading
61
+ title="Single video"
62
+ description="Click any bubble to open the full-screen video viewer with native controls and a download action."
63
+ />
64
+ <StoryGrid>
65
+ <StoryRow
66
+ label="Default"
67
+ composer={
68
+ <VideoAttachment.Composer
69
+ src={VIDEO_SRC}
70
+ poster={VIDEO_POSTER}
71
+ mimeType="video/mp4"
72
+ filename="video-source.mp4"
73
+ onDismiss={handleDismiss}
74
+ />
75
+ }
76
+ sent={
77
+ <VideoAttachment.Sent
78
+ src={VIDEO_SRC}
79
+ poster={VIDEO_POSTER}
80
+ mimeType="video/mp4"
81
+ filename="video-source.mp4"
82
+ />
83
+ }
84
+ received={
85
+ <VideoAttachment.Received
86
+ src={VIDEO_SRC}
87
+ poster={VIDEO_POSTER}
88
+ mimeType="video/mp4"
89
+ filename="video-source.mp4"
90
+ />
91
+ }
92
+ />
93
+ <StoryRow
94
+ label="No poster"
95
+ composer={
96
+ <VideoAttachment.Composer
97
+ src={VIDEO_SRC}
98
+ mimeType="video/mp4"
99
+ filename="video-source.mp4"
100
+ onDismiss={handleDismiss}
101
+ />
102
+ }
103
+ sent={
104
+ <VideoAttachment.Sent
105
+ src={VIDEO_SRC}
106
+ mimeType="video/mp4"
107
+ filename="video-source.mp4"
108
+ />
109
+ }
110
+ received={
111
+ <VideoAttachment.Received
112
+ src={VIDEO_SRC}
113
+ mimeType="video/mp4"
114
+ filename="video-source.mp4"
115
+ />
116
+ }
117
+ />
118
+ </StoryGrid>
119
+ </StoryPage>
120
+ )
121
+
122
+ /** Single video with an accompanying caption. Sent + Received only —
123
+ * composer captions live in the message input, not the attachment
124
+ * preview. */
125
+ export const SingleWithText: StoryFn = () => (
126
+ <StoryPage>
127
+ <StoryHeading
128
+ title="Single video with caption"
129
+ description="Caption renders inside the same bubble, below the video poster. Composer column is intentionally blank — composer captions live in the message input."
130
+ />
131
+ <StoryGrid>
132
+ <StoryRow
133
+ label="Short caption"
134
+ composer={
135
+ <VideoAttachment.Composer
136
+ src={VIDEO_SRC}
137
+ poster={VIDEO_POSTER}
138
+ mimeType="video/mp4"
139
+ filename="video-source.mp4"
140
+ onDismiss={handleDismiss}
141
+ />
142
+ }
143
+ sent={
144
+ <VideoAttachment.Sent
145
+ src={VIDEO_SRC}
146
+ poster={VIDEO_POSTER}
147
+ mimeType="video/mp4"
148
+ filename="video-source.mp4"
149
+ text="Here is the video"
150
+ />
151
+ }
152
+ received={
153
+ <VideoAttachment.Received
154
+ src={VIDEO_SRC}
155
+ poster={VIDEO_POSTER}
156
+ mimeType="video/mp4"
157
+ filename="video-source.mp4"
158
+ text="Here is the video"
159
+ />
160
+ }
161
+ />
162
+ </StoryGrid>
163
+ </StoryPage>
164
+ )
165
+
166
+ /** Stacked video attachments. Sent + Received only — the composer
167
+ * surface accepts a single video at a time. */
168
+ export const Stacked: StoryFn = () => (
169
+ <StoryPage>
170
+ <StoryHeading
171
+ title="Stacked videos"
172
+ description="Each tile shows its own poster + play overlay. Click any tile to open the viewer at that index. Composer column is intentionally blank — the composer accepts a single video at a time."
173
+ />
174
+ <StoryGrid>
175
+ <StoryRow
176
+ label="2 clips"
177
+ composer={
178
+ <VideoAttachment.Composer
179
+ src={VIDEO_SRC}
180
+ poster={VIDEO_POSTER}
181
+ mimeType="video/mp4"
182
+ filename="video-source.mp4"
183
+ onDismiss={handleDismiss}
184
+ />
185
+ }
186
+ sent={
187
+ <VideoAttachment.Sent items={STACK_VIDEOS.slice(0, 2)} filename="clips" />
188
+ }
189
+ received={
190
+ <VideoAttachment.Received
191
+ items={STACK_VIDEOS.slice(0, 2)}
192
+ filename="clips"
193
+ />
194
+ }
195
+ />
196
+ <StoryRow
197
+ label="3 clips"
198
+ composer={
199
+ <VideoAttachment.Composer
200
+ src={VIDEO_SRC}
201
+ poster={VIDEO_POSTER}
202
+ mimeType="video/mp4"
203
+ filename="video-source.mp4"
204
+ onDismiss={handleDismiss}
205
+ />
206
+ }
207
+ sent={
208
+ <VideoAttachment.Sent items={STACK_VIDEOS.slice(0, 3)} filename="clips" />
209
+ }
210
+ received={
211
+ <VideoAttachment.Received
212
+ items={STACK_VIDEOS.slice(0, 3)}
213
+ filename="clips"
214
+ />
215
+ }
216
+ />
217
+ <StoryRow
218
+ label="4 clips"
219
+ composer={
220
+ <VideoAttachment.Composer
221
+ src={VIDEO_SRC}
222
+ poster={VIDEO_POSTER}
223
+ mimeType="video/mp4"
224
+ filename="video-source.mp4"
225
+ onDismiss={handleDismiss}
226
+ />
227
+ }
228
+ sent={<VideoAttachment.Sent items={STACK_VIDEOS} filename="clips" />}
229
+ received={<VideoAttachment.Received items={STACK_VIDEOS} filename="clips" />}
230
+ />
231
+ </StoryGrid>
232
+ </StoryPage>
233
+ )
234
+
235
+ /** Stacked videos with an accompanying caption.
236
+ * Sent + Received only — see `Stacked` for the rationale. */
237
+ export const StackedWithText: StoryFn = () => (
238
+ <StoryPage>
239
+ <StoryHeading
240
+ title="Stacked videos with caption"
241
+ description="Caption sits inside the same bubble, below the stack. Composer column is intentionally blank — the composer accepts a single video at a time."
242
+ />
243
+ <StoryGrid>
244
+ <StoryRow
245
+ label="3 clips + caption"
246
+ composer={
247
+ <VideoAttachment.Composer
248
+ src={VIDEO_SRC}
249
+ poster={VIDEO_POSTER}
250
+ mimeType="video/mp4"
251
+ filename="video-source.mp4"
252
+ onDismiss={handleDismiss}
253
+ />
254
+ }
255
+ sent={
256
+ <VideoAttachment.Sent
257
+ items={STACK_VIDEOS.slice(0, 3)}
258
+ filename="clips"
259
+ text="Three takes — pick your favorite"
260
+ />
261
+ }
262
+ received={
263
+ <VideoAttachment.Received
264
+ items={STACK_VIDEOS.slice(0, 3)}
265
+ filename="clips"
266
+ text="Three takes — pick your favorite"
267
+ />
268
+ }
269
+ />
270
+ </StoryGrid>
271
+ </StoryPage>
272
+ )
@@ -0,0 +1,281 @@
1
+ import { PlayIcon, VideoCameraIcon } from '@phosphor-icons/react'
2
+ import React from 'react'
3
+
4
+ import Bubble from '../_shared/Bubble'
5
+ import DismissButton from '../_shared/DismissButton'
6
+ import MediaStackGrid, {
7
+ type MediaStackTile,
8
+ } from '../_shared/MediaStackGrid'
9
+ import { useViewer } from '../_shared/useViewer'
10
+ import VideoViewer, { type VideoViewerItem } from '../_shared/VideoViewer'
11
+ import {
12
+ bubbleVariantForState,
13
+ type MediaPreloadMode,
14
+ type MessageAttachmentBaseProps,
15
+ type MessageAttachmentState,
16
+ type VideoItem,
17
+ } from '../types'
18
+
19
+ export interface VideoAttachmentSharedProps extends MessageAttachmentBaseProps {
20
+ /** Single video — convenience for the most common case. */
21
+ src?: string
22
+ /** Poster image — preview shown before playback starts. */
23
+ poster?: string
24
+ /** MIME type hint — typed onto the inline `<source>` element. */
25
+ mimeType?: string
26
+ /** Filename surfaced in the viewer toolbar + download default name. */
27
+ filename?: string
28
+ /**
29
+ * Stacked videos. Takes precedence over `src` when set. Renders a
30
+ * 1 / 2 / 3 / 4-tile grid (5+ collapse into a `+N` overflow tile).
31
+ * Sent + Received only — the composer surface intentionally accepts
32
+ * a single attachment at a time.
33
+ */
34
+ items?: VideoItem[]
35
+ /**
36
+ * `<video preload>` hint forwarded into the viewer. Defaults to
37
+ * `'none'` — the poster `<img>` carries the visual weight on the
38
+ * bubble surface, and we shouldn't fetch any video bytes until the
39
+ * user actually opens the viewer. Per-item overrides live on
40
+ * `VideoItem.preload`. The opened `VideoViewer` always preloads
41
+ * metadata for the active item (so duration / first-frame appear
42
+ * immediately) regardless of this value.
43
+ */
44
+ preload?: MediaPreloadMode
45
+ /**
46
+ * Forwarded to the viewer trigger. When omitted the click still
47
+ * opens the built-in `VideoViewer` — supply this for analytics, or
48
+ * return `false` to intercept the open (e.g. switch to a route-
49
+ * based player / confirmation modal). Any other return value
50
+ * (including `void`/`undefined`) lets the built-in viewer open.
51
+ * For stacked attachments the `index` argument identifies the tile.
52
+ */
53
+ onClick?: (index: number) => boolean | void
54
+ }
55
+
56
+ const PlayBadge: React.FC = () => (
57
+ <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
58
+ <span className="flex size-12 items-center justify-center rounded-full bg-black/55 text-white backdrop-blur">
59
+ <PlayIcon className="size-6" weight="fill" aria-hidden />
60
+ </span>
61
+ </div>
62
+ )
63
+
64
+ const PosterTile: React.FC<{ item: VideoItem; index: number }> = ({
65
+ item,
66
+ index,
67
+ }) => (
68
+ <div className="absolute inset-0 size-full bg-[#0d0d0d]">
69
+ {item.poster ? (
70
+ <img
71
+ src={item.poster}
72
+ alt={`Video ${index + 1} thumbnail`}
73
+ draggable={false}
74
+ loading="lazy"
75
+ decoding="async"
76
+ className="absolute inset-0 size-full rounded-md object-cover"
77
+ />
78
+ ) : (
79
+ <div className="absolute inset-0 flex items-center justify-center">
80
+ <VideoCameraIcon
81
+ className="size-16 rounded-md text-white/30"
82
+ weight="regular"
83
+ aria-hidden
84
+ />
85
+ </div>
86
+ )}
87
+ <PlayBadge />
88
+ </div>
89
+ )
90
+
91
+ const tileFromItem = (
92
+ item: VideoItem,
93
+ index: number,
94
+ totalCount: number
95
+ ): MediaStackTile => ({
96
+ ariaLabel: `Play video ${index + 1} of ${totalCount}`,
97
+ content: <PosterTile item={item} index={index} />,
98
+ })
99
+
100
+ const resolveItems = ({
101
+ src,
102
+ poster,
103
+ mimeType,
104
+ preload,
105
+ items,
106
+ }: {
107
+ src?: string
108
+ poster?: string
109
+ mimeType?: string
110
+ preload?: MediaPreloadMode
111
+ items?: VideoItem[]
112
+ }): VideoItem[] => {
113
+ if (items && items.length > 0) {
114
+ return preload ? items.map((it) => ({ ...it, preload: it.preload ?? preload })) : items
115
+ }
116
+ if (src) return [{ src, poster, mimeType, preload }]
117
+ return []
118
+ }
119
+
120
+ interface InternalVideoRowProps extends VideoAttachmentSharedProps {
121
+ state: MessageAttachmentState
122
+ }
123
+
124
+ const buildViewerItems = (
125
+ resolvedItems: VideoItem[],
126
+ filename?: string
127
+ ): VideoViewerItem[] =>
128
+ resolvedItems.map((item, index) => ({
129
+ src: item.src,
130
+ poster: item.poster,
131
+ mimeType: item.mimeType,
132
+ preload: item.preload,
133
+ filename:
134
+ filename && resolvedItems.length === 1
135
+ ? filename
136
+ : filename
137
+ ? `${filename} (${index + 1})`
138
+ : undefined,
139
+ }))
140
+
141
+ /**
142
+ * Composer rendering — bare 280px-square poster with a play badge,
143
+ * a dismiss `×` overlay, and `rounded-md` corners. Renders without the
144
+ * shared `Bubble` chrome (no border / no background / no padding) so
145
+ * the in-progress attachment looks like a draft preview rather than a
146
+ * sent message. Single attachment only — `items` and `text` are not
147
+ * supported on the composer surface.
148
+ */
149
+ const VideoComposerInner: React.FC<{
150
+ src: string
151
+ poster?: string
152
+ mimeType?: string
153
+ filename?: string
154
+ preload?: MediaPreloadMode
155
+ onClick?: (index: number) => boolean | void
156
+ onDismiss?: () => void
157
+ }> = ({ src, poster, mimeType, filename, preload, onClick, onDismiss }) => {
158
+ const { viewerOpen, viewerIndex, handleActivate, closeViewer } = useViewer(
159
+ onClick
160
+ )
161
+
162
+ return (
163
+ <div className="relative w-fit">
164
+ <button
165
+ type="button"
166
+ onClick={() => handleActivate(0)}
167
+ aria-label="Play video"
168
+ className="relative block size-[280px] cursor-pointer overflow-hidden rounded-md outline-none focus-visible:ring-2 focus-visible:ring-black/40"
169
+ >
170
+ <PosterTile item={{ src, poster, mimeType }} index={0} />
171
+ </button>
172
+ {onDismiss ? (
173
+ <div className="absolute right-2 top-2 z-10">
174
+ <DismissButton onClick={onDismiss} />
175
+ </div>
176
+ ) : null}
177
+
178
+ <VideoViewer
179
+ open={viewerOpen}
180
+ items={buildViewerItems([{ src, poster, mimeType, preload }], filename)}
181
+ initialIndex={viewerIndex}
182
+ onClose={closeViewer}
183
+ />
184
+ </div>
185
+ )
186
+ }
187
+
188
+ /**
189
+ * Sent / Received rendering — wrapped in the shared `Bubble` chrome,
190
+ * supports single or stacked items, and renders an optional caption
191
+ * below the media.
192
+ */
193
+ const VideoBubbleRow: React.FC<InternalVideoRowProps> = ({
194
+ state,
195
+ src,
196
+ poster,
197
+ mimeType,
198
+ filename,
199
+ items,
200
+ text,
201
+ groupPosition,
202
+ preload,
203
+ onClick,
204
+ }) => {
205
+ const resolvedItems = resolveItems({ src, poster, mimeType, preload, items })
206
+ const variant = bubbleVariantForState(state)
207
+ const { viewerOpen, viewerIndex, handleActivate, closeViewer } = useViewer(
208
+ onClick
209
+ )
210
+
211
+ if (resolvedItems.length === 0) {
212
+ return null
213
+ }
214
+
215
+ const tiles: MediaStackTile[] = resolvedItems.map((item, index) =>
216
+ tileFromItem(item, index, resolvedItems.length)
217
+ )
218
+
219
+ return (
220
+ <Bubble
221
+ variant={variant}
222
+ text={text}
223
+ groupPosition={groupPosition}
224
+ data-testid="video-attachment"
225
+ >
226
+ <div className="relative">
227
+ <MediaStackGrid
228
+ tiles={tiles}
229
+ onTileActivate={handleActivate}
230
+ className="overflow-hidden rounded-md"
231
+ />
232
+ </div>
233
+
234
+ <VideoViewer
235
+ open={viewerOpen}
236
+ items={buildViewerItems(resolvedItems, filename)}
237
+ initialIndex={viewerIndex}
238
+ onClose={closeViewer}
239
+ />
240
+ </Bubble>
241
+ )
242
+ }
243
+
244
+ /**
245
+ * Composer-only props. Single video (`src`) is required; stacked
246
+ * `items` and `text` captions are not supported in the composer state.
247
+ */
248
+ export interface VideoComposerProps {
249
+ src: string
250
+ poster?: string
251
+ mimeType?: string
252
+ filename?: string
253
+ /**
254
+ * `<video preload>` hint forwarded into the viewer. Defaults to
255
+ * `'none'`. See `VideoAttachmentSharedProps.preload`.
256
+ */
257
+ preload?: MediaPreloadMode
258
+ onClick?: (index: number) => boolean | void
259
+ onDismiss?: () => void
260
+ }
261
+
262
+ export type VideoSentProps = VideoAttachmentSharedProps
263
+ export type VideoReceivedProps = VideoAttachmentSharedProps
264
+
265
+ const VideoComposer: React.FC<VideoComposerProps> = (props) => (
266
+ <VideoComposerInner {...props} />
267
+ )
268
+ const VideoSent: React.FC<VideoSentProps> = (props) => (
269
+ <VideoBubbleRow {...props} state="sent" />
270
+ )
271
+ const VideoReceived: React.FC<VideoReceivedProps> = (props) => (
272
+ <VideoBubbleRow {...props} state="received" />
273
+ )
274
+
275
+ const VideoAttachment = {
276
+ Composer: VideoComposer,
277
+ Sent: VideoSent,
278
+ Received: VideoReceived,
279
+ }
280
+
281
+ export default VideoAttachment