Private
Public Access
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:
@@ -105,6 +105,11 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.composer-attach-wrap {
|
||||||
|
position: relative;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
.composer-attach {
|
.composer-attach {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
@@ -119,6 +124,42 @@
|
|||||||
cursor: pointer;
|
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) {
|
.composer-attach:hover:not(:disabled) {
|
||||||
color: var(--ds-text);
|
color: var(--ds-text);
|
||||||
border-color: var(--ds-accent);
|
border-color: var(--ds-accent);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type FormEvent,
|
type FormEvent,
|
||||||
type KeyboardEvent,
|
type KeyboardEvent,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
|
import { useEscapeKey } from '../hooks/useEscapeKey'
|
||||||
import { useOnlineStatus } from '../hooks/useOnlineStatus'
|
import { useOnlineStatus } from '../hooks/useOnlineStatus'
|
||||||
import { uploadRoomFile, uploadRoomImage } from '../api/rooms'
|
import { uploadRoomFile, uploadRoomImage } from '../api/rooms'
|
||||||
import { getUploadLimit } from '../api/uploads'
|
import { getUploadLimit } from '../api/uploads'
|
||||||
@@ -61,6 +62,33 @@ function detectRoomReferenceQuery(text: string, cursor: number): TriggerQuery |
|
|||||||
return detectTriggerQuery(text, cursor, '#')
|
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) {
|
export function Composer({ roomId, roomName, members, rooms, disabled, onSend }: ComposerProps) {
|
||||||
const [value, setValue] = useState('')
|
const [value, setValue] = useState('')
|
||||||
const [pendingImage, setPendingImage] = useState<{ id: string; previewUrl: string } | null>(null)
|
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 [roomQuery, setRoomQuery] = useState<TriggerQuery | null>(null)
|
||||||
const [roomActiveIndex, setRoomActiveIndex] = useState(0)
|
const [roomActiveIndex, setRoomActiveIndex] = useState(0)
|
||||||
const [dragActive, setDragActive] = useState(false)
|
const [dragActive, setDragActive] = useState(false)
|
||||||
|
const [attachMenuOpen, setAttachMenuOpen] = useState(false)
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(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,
|
// Counts nested dragenter/dragleave pairs (the overlay, the composer box,
|
||||||
// the textarea are all separate elements a drag passes over) so the
|
// the textarea are all separate elements a drag passes over) so the
|
||||||
// highlight doesn't flicker off every time the pointer crosses a child
|
// 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>}
|
{uploadError && <div className="composer-status composer-error">{uploadError}</div>}
|
||||||
<div className="composer-box">
|
<div className="composer-box">
|
||||||
|
<input
|
||||||
|
ref={photoInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*,video/*"
|
||||||
|
className="composer-file-input"
|
||||||
|
onChange={handleFileSelected}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
className="composer-file-input"
|
className="composer-file-input"
|
||||||
onChange={handleFileSelected}
|
onChange={handleFileSelected}
|
||||||
/>
|
/>
|
||||||
<button
|
<div className="composer-attach-wrap">
|
||||||
type="button"
|
<button
|
||||||
className="composer-attach"
|
type="button"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
className="composer-attach"
|
||||||
disabled={disabled || uploading}
|
onClick={() => setAttachMenuOpen((v) => !v)}
|
||||||
aria-label="Attach a file"
|
disabled={disabled || uploading}
|
||||||
>
|
aria-label="Attach a photo or file"
|
||||||
{uploading ? (
|
aria-expanded={attachMenuOpen}
|
||||||
<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" />
|
{uploading ? (
|
||||||
</svg>
|
<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 width="15" height="15" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
</svg>
|
||||||
<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"
|
<svg width="15" height="15" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||||
stroke="currentColor"
|
<path
|
||||||
strokeWidth="1.6"
|
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"
|
||||||
strokeLinecap="round"
|
stroke="currentColor"
|
||||||
strokeLinejoin="round"
|
strokeWidth="1.6"
|
||||||
/>
|
strokeLinecap="round"
|
||||||
</svg>
|
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">
|
<div className="composer-emoji-wrap">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
Reference in New Issue
Block a user