Skip to content

Commit 997a3b3

Browse files
committed
feat: add new styles for unsupported attachments (preview, message, channel preview)
1 parent 443b9a8 commit 997a3b3

14 files changed

Lines changed: 652 additions & 51 deletions

File tree

examples/vite/src/AppSettings/ActionsMenu/ActionsMenu.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ import {
1313
NotificationPromptDialog,
1414
notificationPromptDialogId,
1515
} from './NotificationPromptDialog';
16+
import {
17+
AttachmentPromptDialog,
18+
attachmentPromptDialogId,
19+
} from './AttachmentPromptDialog';
1620

1721
const actionsMenuDialogId = 'app-actions-menu';
1822

@@ -72,6 +76,20 @@ function TriggerNotificationAction({ onTrigger }: { onTrigger: () => void }) {
7276
);
7377
}
7478

79+
function TriggerAttachmentAction({ onTrigger }: { onTrigger: () => void }) {
80+
const { closeMenu } = useContextMenuContext();
81+
82+
return (
83+
<ContextMenuButton
84+
label='Message Composer'
85+
onClick={() => {
86+
closeMenu();
87+
onTrigger();
88+
}}
89+
/>
90+
);
91+
}
92+
7593
const ActionsMenuInner = ({ iconOnly }: { iconOnly: boolean }) => {
7694
const [menuButtonElement, setMenuButtonElement] = useState<HTMLButtonElement | null>(
7795
null,
@@ -82,7 +100,9 @@ const ActionsMenuInner = ({ iconOnly }: { iconOnly: boolean }) => {
82100
const { dialog: notificationDialog } = useDialogOnNearestManager({
83101
id: notificationPromptDialogId,
84102
});
85-
103+
const { dialog: attachmentDialog } = useDialogOnNearestManager({
104+
id: attachmentPromptDialogId,
105+
});
86106
const menuIsOpen = useDialogIsOpen(actionsMenuDialogId, dialogManager?.id);
87107

88108
return (
@@ -105,8 +125,10 @@ const ActionsMenuInner = ({ iconOnly }: { iconOnly: boolean }) => {
105125
trapFocus
106126
>
107127
<TriggerNotificationAction onTrigger={notificationDialog.open} />
128+
<TriggerAttachmentAction onTrigger={attachmentDialog.open} />
108129
</ContextMenu>
109130
<NotificationPromptDialog referenceElement={menuButtonElement} />
131+
<AttachmentPromptDialog referenceElement={menuButtonElement} />
110132
</div>
111133
);
112134
};
Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
import { useCallback, useEffect, useRef, useState } from 'react';
2+
import type { PointerEvent as ReactPointerEvent } from 'react';
3+
import type { LocalAttachment } from 'stream-chat';
4+
import {
5+
DialogAnchor,
6+
Prompt,
7+
useChatContext,
8+
useDialogIsOpen,
9+
useDialogOnNearestManager,
10+
} from 'stream-chat-react';
11+
12+
export const attachmentPromptDialogId = 'app-attachment-prompt-dialog';
13+
type AttachmentEditorTab = 'unsupported-file' | 'unsupported-object';
14+
15+
const VIEWPORT_MARGIN = 8;
16+
const defaultUnsupportedAttachment = {
17+
asset_url: 'https://example.com/unsupported.bin',
18+
file_size: 128000,
19+
localMetadata: {
20+
id: 'unsupported-attachment-1',
21+
uploadProgress: 100,
22+
uploadState: 'finished',
23+
},
24+
mime_type: 'application/octet-stream',
25+
title: 'unsupported.bin',
26+
type: 'unsupported',
27+
};
28+
const defaultUnsupportedObjectAttachment = {
29+
localMetadata: {
30+
id: 'unsupported-object-1',
31+
uploadProgress: 100,
32+
uploadState: 'finished',
33+
},
34+
debug: true,
35+
metadata: { randomNumber: 7, source: 'vite-preview' },
36+
title: 'custom payload',
37+
type: 'custom',
38+
};
39+
const initialUnsupportedFileValue = JSON.stringify(defaultUnsupportedAttachment, null, 2);
40+
const initialUnsupportedObjectValue = JSON.stringify(
41+
defaultUnsupportedObjectAttachment,
42+
null,
43+
2,
44+
);
45+
46+
const clamp = (value: number, min: number, max: number) => {
47+
if (max < min) return min;
48+
return Math.min(Math.max(value, min), max);
49+
};
50+
51+
export const AttachmentPromptDialog = ({
52+
referenceElement,
53+
}: {
54+
referenceElement: HTMLElement | null;
55+
}) => {
56+
const [activeTab, setActiveTab] = useState<AttachmentEditorTab>('unsupported-file');
57+
const [unsupportedFileInput, setUnsupportedFileInput] = useState(
58+
initialUnsupportedFileValue,
59+
);
60+
const [unsupportedObjectInput, setUnsupportedObjectInput] = useState(
61+
initialUnsupportedObjectValue,
62+
);
63+
const [errorMessage, setErrorMessage] = useState<string | null>(null);
64+
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
65+
const shellRef = useRef<HTMLDivElement | null>(null);
66+
const { channel } = useChatContext();
67+
const { dialog, dialogManager } = useDialogOnNearestManager({
68+
id: attachmentPromptDialogId,
69+
});
70+
const dialogIsOpen = useDialogIsOpen(attachmentPromptDialogId, dialogManager?.id);
71+
72+
useEffect(() => {
73+
if (dialogIsOpen) return;
74+
setActiveTab('unsupported-file');
75+
setUnsupportedFileInput(initialUnsupportedFileValue);
76+
setUnsupportedObjectInput(initialUnsupportedObjectValue);
77+
setErrorMessage(null);
78+
setDragOffset({ x: 0, y: 0 });
79+
}, [dialogIsOpen]);
80+
81+
useEffect(() => {
82+
if (!dialogIsOpen) return;
83+
84+
const clampToViewport = () => {
85+
const shell = shellRef.current;
86+
if (!shell) return;
87+
88+
const rect = shell.getBoundingClientRect();
89+
const nextLeft = clamp(
90+
rect.left,
91+
VIEWPORT_MARGIN,
92+
window.innerWidth - rect.width - VIEWPORT_MARGIN,
93+
);
94+
const nextTop = clamp(
95+
rect.top,
96+
VIEWPORT_MARGIN,
97+
window.innerHeight - rect.height - VIEWPORT_MARGIN,
98+
);
99+
100+
if (nextLeft === rect.left && nextTop === rect.top) return;
101+
102+
setDragOffset((current) => ({
103+
x: current.x + (nextLeft - rect.left),
104+
y: current.y + (nextTop - rect.top),
105+
}));
106+
};
107+
108+
window.addEventListener('resize', clampToViewport);
109+
110+
return () => {
111+
window.removeEventListener('resize', clampToViewport);
112+
};
113+
}, [dialogIsOpen]);
114+
115+
const closeDialog = useCallback(() => {
116+
dialog.close();
117+
}, [dialog]);
118+
119+
const attachToComposer = useCallback(
120+
(tab: AttachmentEditorTab) => {
121+
if (!channel?.messageComposer) {
122+
setErrorMessage('No active channel selected');
123+
return;
124+
}
125+
126+
let parsedAttachment: LocalAttachment;
127+
const attachmentInput =
128+
tab === 'unsupported-file' ? unsupportedFileInput : unsupportedObjectInput;
129+
try {
130+
parsedAttachment = JSON.parse(attachmentInput);
131+
} catch {
132+
setErrorMessage('Attachment is not valid JSON');
133+
return;
134+
}
135+
136+
const currentAttachments =
137+
channel.messageComposer.attachmentManager.state.getLatestValue().attachments;
138+
139+
channel.messageComposer.attachmentManager.upsertAttachments([
140+
...currentAttachments,
141+
parsedAttachment,
142+
]);
143+
closeDialog();
144+
},
145+
[channel, closeDialog, unsupportedFileInput, unsupportedObjectInput],
146+
);
147+
148+
const handleHeaderPointerDown = useCallback(
149+
(event: ReactPointerEvent<HTMLDivElement>) => {
150+
if (event.button !== 0) return;
151+
if (!(event.target instanceof HTMLElement)) return;
152+
if (event.target.closest('button')) return;
153+
154+
const shell = shellRef.current;
155+
if (!shell) return;
156+
157+
event.preventDefault();
158+
159+
const startClientX = event.clientX;
160+
const startClientY = event.clientY;
161+
const startOffset = dragOffset;
162+
const startRect = shell.getBoundingClientRect();
163+
164+
const handlePointerMove = (moveEvent: PointerEvent) => {
165+
const nextLeft = clamp(
166+
startRect.left + (moveEvent.clientX - startClientX),
167+
VIEWPORT_MARGIN,
168+
window.innerWidth - startRect.width - VIEWPORT_MARGIN,
169+
);
170+
const nextTop = clamp(
171+
startRect.top + (moveEvent.clientY - startClientY),
172+
VIEWPORT_MARGIN,
173+
window.innerHeight - startRect.height - VIEWPORT_MARGIN,
174+
);
175+
176+
setDragOffset({
177+
x: startOffset.x + (nextLeft - startRect.left),
178+
y: startOffset.y + (nextTop - startRect.top),
179+
});
180+
};
181+
182+
const handlePointerUp = () => {
183+
window.removeEventListener('pointermove', handlePointerMove);
184+
window.removeEventListener('pointerup', handlePointerUp);
185+
};
186+
187+
window.addEventListener('pointermove', handlePointerMove);
188+
window.addEventListener('pointerup', handlePointerUp);
189+
},
190+
[dragOffset],
191+
);
192+
193+
const shellStyle = {
194+
transform: `translate(${dragOffset.x}px, ${dragOffset.y}px)`,
195+
};
196+
197+
return (
198+
<DialogAnchor
199+
allowFlip
200+
className='app__attachment-dialog'
201+
dialogManagerId={dialogManager?.id}
202+
id={attachmentPromptDialogId}
203+
placement='right-start'
204+
referenceElement={referenceElement}
205+
tabIndex={-1}
206+
trapFocus
207+
updatePositionOnContentResize
208+
>
209+
<div className='app__attachment-dialog__shell' ref={shellRef} style={shellStyle}>
210+
<Prompt.Root className='app__attachment-dialog__prompt'>
211+
<div
212+
className='app__attachment-dialog__drag-handle'
213+
onPointerDown={handleHeaderPointerDown}
214+
>
215+
<Prompt.Header close={closeDialog} title='Message Composer' />
216+
</div>
217+
<Prompt.Body className='app__attachment-dialog__body'>
218+
<div className='app__attachment-dialog__subsection'>
219+
<h3 className='app__attachment-dialog__subsection-title'>
220+
Attach Unsupported Attachment
221+
</h3>
222+
<div
223+
aria-label='Attachment type'
224+
className='app__attachment-dialog__tabs'
225+
role='tablist'
226+
>
227+
<button
228+
aria-selected={activeTab === 'unsupported-file'}
229+
className='app__attachment-dialog__tab'
230+
onClick={() => {
231+
setActiveTab('unsupported-file');
232+
if (errorMessage) setErrorMessage(null);
233+
}}
234+
role='tab'
235+
type='button'
236+
>
237+
Unsupported file
238+
</button>
239+
<button
240+
aria-selected={activeTab === 'unsupported-object'}
241+
className='app__attachment-dialog__tab'
242+
onClick={() => {
243+
setActiveTab('unsupported-object');
244+
if (errorMessage) setErrorMessage(null);
245+
}}
246+
role='tab'
247+
type='button'
248+
>
249+
Unsupported object
250+
</button>
251+
</div>
252+
<label className='app__attachment-dialog__field'>
253+
<span className='app__attachment-dialog__field-label'>
254+
Attachment JSON
255+
</span>
256+
<textarea
257+
className='app__attachment-dialog__textarea'
258+
onChange={(event) => {
259+
if (activeTab === 'unsupported-file') {
260+
setUnsupportedFileInput(event.target.value);
261+
} else {
262+
setUnsupportedObjectInput(event.target.value);
263+
}
264+
if (errorMessage) setErrorMessage(null);
265+
}}
266+
rows={12}
267+
spellCheck={false}
268+
value={
269+
activeTab === 'unsupported-file'
270+
? unsupportedFileInput
271+
: unsupportedObjectInput
272+
}
273+
/>
274+
</label>
275+
<div className='app__attachment-dialog__subsection-actions'>
276+
{activeTab === 'unsupported-file' ? (
277+
<Prompt.FooterControlsButtonPrimary
278+
size='sm'
279+
onClick={() => attachToComposer('unsupported-file')}
280+
>
281+
Attach Unsupported file
282+
</Prompt.FooterControlsButtonPrimary>
283+
) : (
284+
<Prompt.FooterControlsButtonPrimary
285+
size='sm'
286+
onClick={() => attachToComposer('unsupported-object')}
287+
>
288+
Attach Unsupported object
289+
</Prompt.FooterControlsButtonPrimary>
290+
)}
291+
</div>
292+
{errorMessage && (
293+
<div className='app__attachment-dialog__error' role='alert'>
294+
{errorMessage}
295+
</div>
296+
)}
297+
</div>
298+
</Prompt.Body>
299+
</Prompt.Root>
300+
</div>
301+
</DialogAnchor>
302+
);
303+
};

0 commit comments

Comments
 (0)