Split the composer's attach button into Photo/video and File (#29)

On mobile, a file input with no accept hint (needed to allow arbitrary
file attachments) makes some Android browsers fall back to a generic
chooser -- Camera, Camera Video, Files -- with no direct Photos/Gallery
shortcut, confirmed via a screenshot showing exactly that. Android
can't reliably offer both "any file type" and a gallery shortcut from
a single input, so the attach button now opens a small menu: "Photo or
video" uses a new input with accept="image/*,video/*" (should surface
the OS media picker's gallery shortcut), "File" keeps today's
unrestricted picker. Upload routing (handleFile) is unchanged either
way.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 18:36:49 -06:00
co-authored by Claude Sonnet 5
parent c094ce0975
commit 5bd1716c94
2 changed files with 124 additions and 22 deletions
+41
View File
@@ -105,6 +105,11 @@
cursor: not-allowed;
}
.composer-attach-wrap {
position: relative;
flex: none;
}
.composer-attach {
width: 36px;
height: 36px;
@@ -119,6 +124,42 @@
cursor: pointer;
}
.composer-attach-menu-scrim {
position: fixed;
inset: 0;
z-index: 30;
}
.composer-attach-menu {
position: absolute;
bottom: calc(100% + 8px);
left: 0;
z-index: 31;
background: var(--card-bg);
border: 1px solid var(--ds-border);
border-radius: var(--radius);
padding: var(--sp-2);
min-width: 160px;
display: flex;
flex-direction: column;
gap: 2px;
}
.composer-attach-menu button[role='menuitem'] {
background: transparent;
border: none;
color: var(--ds-text);
text-align: left;
padding: 8px;
border-radius: 6px;
font-size: 0.86rem;
cursor: pointer;
}
.composer-attach-menu button[role='menuitem']:hover {
background: var(--ds-surface-2);
}
.composer-attach:hover:not(:disabled) {
color: var(--ds-text);
border-color: var(--ds-accent);
+83 -22
View File
@@ -8,6 +8,7 @@ import {
type FormEvent,
type KeyboardEvent,
} from 'react'
import { useEscapeKey } from '../hooks/useEscapeKey'
import { useOnlineStatus } from '../hooks/useOnlineStatus'
import { uploadRoomFile, uploadRoomImage } from '../api/rooms'
import { getUploadLimit } from '../api/uploads'
@@ -61,6 +62,33 @@ function detectRoomReferenceQuery(text: string, cursor: number): TriggerQuery |
return detectTriggerQuery(text, cursor, '#')
}
interface AttachMenuProps {
onPickPhoto: () => void
onPickFile: () => void
onClose: () => void
}
// #29: splits into two explicit choices rather than one unrestricted file
// input -- see photoInputRef's comment on the Composer below for why a
// single input can't reliably offer both "any file type" and a mobile
// gallery shortcut at once.
function AttachMenu({ onPickPhoto, onPickFile, onClose }: AttachMenuProps) {
useEscapeKey(onClose)
return (
<>
<div className="composer-attach-menu-scrim" onClick={onClose} />
<div className="composer-attach-menu" role="menu">
<button type="button" role="menuitem" onClick={onPickPhoto}>
Photo or video
</button>
<button type="button" role="menuitem" onClick={onPickFile}>
File
</button>
</div>
</>
)
}
export function Composer({ roomId, roomName, members, rooms, disabled, onSend }: ComposerProps) {
const [value, setValue] = useState('')
const [pendingImage, setPendingImage] = useState<{ id: string; previewUrl: string } | null>(null)
@@ -76,8 +104,18 @@ export function Composer({ roomId, roomName, members, rooms, disabled, onSend }:
const [roomQuery, setRoomQuery] = useState<TriggerQuery | null>(null)
const [roomActiveIndex, setRoomActiveIndex] = useState(0)
const [dragActive, setDragActive] = useState(false)
const [attachMenuOpen, setAttachMenuOpen] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
// #29: a separate input with an image/video accept hint, so mobile
// browsers offer their media picker (with a direct Photos/Gallery
// shortcut) instead of the generic chooser a fully-unrestricted `accept`
// falls back to (Camera / Camera Video / Files, no gallery). The
// unrestricted `fileInputRef` above still exists for the "File" choice --
// Android can't reliably offer both a gallery shortcut and "any file
// type" from a single input, so the attach button now opens a small menu
// to pick which one you want first.
const photoInputRef = useRef<HTMLInputElement>(null)
// Counts nested dragenter/dragleave pairs (the overlay, the composer box,
// the textarea are all separate elements a drag passes over) so the
// highlight doesn't flicker off every time the pointer crosses a child
@@ -362,35 +400,58 @@ export function Composer({ roomId, roomName, members, rooms, disabled, onSend }:
)}
{uploadError && <div className="composer-status composer-error">{uploadError}</div>}
<div className="composer-box">
<input
ref={photoInputRef}
type="file"
accept="image/*,video/*"
className="composer-file-input"
onChange={handleFileSelected}
/>
<input
ref={fileInputRef}
type="file"
className="composer-file-input"
onChange={handleFileSelected}
/>
<button
type="button"
className="composer-attach"
onClick={() => fileInputRef.current?.click()}
disabled={disabled || uploading}
aria-label="Attach a file"
>
{uploading ? (
<svg className="composer-spinner" width="15" height="15" viewBox="0 0 20 20" aria-hidden="true">
<circle cx="10" cy="10" r="7" stroke="currentColor" strokeWidth="2.4" fill="none" strokeDasharray="30 14" />
</svg>
) : (
<svg width="15" height="15" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path
d="M13.5 6.5 8 12a2.1 2.1 0 0 0 3 3l5.5-5.5a4 4 0 0 0-5.7-5.7L4.8 9.8a5.7 5.7 0 0 0 8 8"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<div className="composer-attach-wrap">
<button
type="button"
className="composer-attach"
onClick={() => setAttachMenuOpen((v) => !v)}
disabled={disabled || uploading}
aria-label="Attach a photo or file"
aria-expanded={attachMenuOpen}
>
{uploading ? (
<svg className="composer-spinner" width="15" height="15" viewBox="0 0 20 20" aria-hidden="true">
<circle cx="10" cy="10" r="7" stroke="currentColor" strokeWidth="2.4" fill="none" strokeDasharray="30 14" />
</svg>
) : (
<svg width="15" height="15" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path
d="M13.5 6.5 8 12a2.1 2.1 0 0 0 3 3l5.5-5.5a4 4 0 0 0-5.7-5.7L4.8 9.8a5.7 5.7 0 0 0 8 8"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</button>
{attachMenuOpen && (
<AttachMenu
onPickPhoto={() => {
setAttachMenuOpen(false)
photoInputRef.current?.click()
}}
onPickFile={() => {
setAttachMenuOpen(false)
fileInputRef.current?.click()
}}
onClose={() => setAttachMenuOpen(false)}
/>
)}
</button>
</div>
<div className="composer-emoji-wrap">
<button
type="button"