feat(P7.T3): composer toolbar — 5 inline buttons + popover for Bild/Poll/Aktivitäten

This commit is contained in:
byGalax
2026-05-17 01:18:32 +02:00
parent 28b6d64936
commit 940432d287
+20 -85
View File
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'; import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';
import { ComposerActionsMenu } from '../components/ComposerActionsMenu';
import { ConversationHeader } from '../components/ConversationHeader'; import { ConversationHeader } from '../components/ConversationHeader';
import { EmojiPicker } from '../components/EmojiPicker'; import { EmojiPicker } from '../components/EmojiPicker';
import { EmptyState } from '../components/EmptyState'; import { EmptyState } from '../components/EmptyState';
@@ -16,10 +17,8 @@ import {
ArrowRightIcon, ArrowRightIcon,
ChevronDownIcon, ChevronDownIcon,
ChevronUpIcon, ChevronUpIcon,
EyeOffIcon,
PencilIcon, PencilIcon,
PlusIcon, PlusIcon,
PollIcon,
ReplyIcon, ReplyIcon,
SearchIcon, SearchIcon,
SendIcon, SendIcon,
@@ -237,6 +236,8 @@ export function ConversationPage() {
const [mentionState, setMentionState] = useState<{ query: string; start: number } | null>(null); const [mentionState, setMentionState] = useState<{ query: string; start: number } | null>(null);
const [emojiOpen, setEmojiOpen] = useState(false); const [emojiOpen, setEmojiOpen] = useState(false);
const [gifPickerOpen, setGifPickerOpen] = useState(false); const [gifPickerOpen, setGifPickerOpen] = useState(false);
const [actionsMenuOpen, setActionsMenuOpen] = useState(false);
const actionsMenuAnchorRef = useRef<HTMLButtonElement>(null);
// Sticky toggle: when on, the next image(s) sent are marked view-once. // Sticky toggle: when on, the next image(s) sent are marked view-once.
// Auto-clears on a successful send so the composer doesn't accidentally // Auto-clears on a successful send so the composer doesn't accidentally
// burn the message-after-next. // burn the message-after-next.
@@ -1243,55 +1244,32 @@ export function ConversationPage() {
className="hidden" className="hidden"
onChange={(e) => handleFilesChosen(e.target.files)} onChange={(e) => handleFilesChosen(e.target.files)}
/> />
{/* [+] popover trigger — opens ComposerActionsMenu (file/poll/whiteboard/watch/game) */}
<button <button
ref={actionsMenuAnchorRef}
type="button" type="button"
onClick={() => fileInputRef.current?.click()} onClick={() => setActionsMenuOpen((v) => !v)}
aria-label="Datei anhängen" aria-label={t('app:composer.more_actions', { defaultValue: 'Mehr Aktionen' })}
title="Datei anhängen" title={t('app:composer.more_actions', { defaultValue: 'Mehr Aktionen' })}
aria-expanded={actionsMenuOpen}
className="inline-flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 dark:hover:bg-[#313338]" className="inline-flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 dark:hover:bg-[#313338]"
> >
<PlusIcon className="h-4 w-4" /> <PlusIcon className="h-4 w-4" />
</button> </button>
<button <ComposerActionsMenu
type="button" anchorRef={actionsMenuAnchorRef}
onClick={() => { open={actionsMenuOpen}
onClose={() => setActionsMenuOpen(false)}
onAttachFile={() => fileInputRef.current?.click()}
onCreatePoll={() => {
setPollError(null); setPollError(null);
setPollDialogOpen(true); setPollDialogOpen(true);
}} }}
aria-label="Umfrage erstellen" onCreateWhiteboard={() => void handleCreateWhiteboard()}
title="Umfrage erstellen" onStartWatchTogether={() => setWatchDialogOpen(true)}
className="inline-flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 dark:hover:bg-[#313338]" onStartGame={() => setGameDialogOpen(true)}
> canStartGame={conversation?.members?.length === 2}
<PollIcon className="h-4 w-4" /> />
</button>
<button
type="button"
onClick={() => void handleCreateWhiteboard()}
disabled={creatingWhiteboard}
title={t('app:composer.whiteboard', { defaultValue: 'Whiteboard' })}
aria-label={t('app:composer.whiteboard', { defaultValue: 'Whiteboard' })}
className="inline-flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-[#313338]"
>
<WhiteboardIcon className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setWatchDialogOpen(true)}
title={t('app:composer.watch_together', { defaultValue: 'Watch Together' })}
aria-label={t('app:composer.watch_together', { defaultValue: 'Watch Together' })}
className="inline-flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 dark:hover:bg-[#313338]"
>
<PlayBoxIcon className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setGameDialogOpen(true)}
title={t('app:composer.game', { defaultValue: 'Spielen' })}
aria-label={t('app:composer.game', { defaultValue: 'Spielen' })}
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-md text-fg-muted transition hover:bg-surface-3 hover:text-fg"
>
<GameIcon className="h-4 w-4" />
</button>
<div className="relative"> <div className="relative">
<button <button
type="button" type="button"
@@ -1337,20 +1315,6 @@ export function ConversationPage() {
onPick={(gif) => void handleGifPick(gif)} onPick={(gif) => void handleGifPick(gif)}
/> />
</div> </div>
<button
type="button"
onClick={() => setViewOnceNext((v) => !v)}
aria-pressed={viewOnceNext}
title={viewOnceNext ? 'Nächstes Bild: einmal ansehen' : 'Nächstes Bild: normal'}
className={
'flex h-9 w-9 cursor-pointer items-center justify-center rounded-md transition ' +
(viewOnceNext
? 'bg-accent/20 text-accent'
: 'text-fg-muted hover:bg-surface-3 hover:text-fg')
}
>
<EyeOffIcon className="h-4 w-4" />
</button>
<VoiceRecorder <VoiceRecorder
disabled={sending} disabled={sending}
onComplete={async (file) => { onComplete={async (file) => {
@@ -1898,32 +1862,3 @@ function AttachmentPreview({
); );
} }
function WhiteboardIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
strokeLinecap="round" strokeLinejoin="round" {...props}>
<rect x="3" y="4" width="18" height="13" rx="2" />
<path d="M8 21h8M12 17v4" />
</svg>
);
}
function PlayBoxIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
strokeLinecap="round" strokeLinejoin="round" {...props}>
<rect x="3" y="4" width="18" height="14" rx="2" />
<path d="M10 9l5 3-5 3V9z" fill="currentColor" stroke="none" />
</svg>
);
}
function GameIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
strokeLinecap="round" strokeLinejoin="round" {...props}>
<rect x="3" y="6" width="18" height="12" rx="3" />
<path d="M8 12h4M10 10v4M16 11v.01M16 14v.01" />
</svg>
);
}