feat(P5A.T5): composer Watch-Together button + bubble dispatch

This commit is contained in:
byGalax
2026-05-16 21:30:22 +02:00
parent 7d5f3b37cd
commit ecbd11e369
3 changed files with 150 additions and 1 deletions
@@ -442,6 +442,32 @@ export function MessageBubble({
defaultValue: 'Nachricht nicht lesbar', defaultValue: 'Nachricht nicht lesbar',
})} })}
</span> </span>
) : parsed.kind === 'watch_together' ? (
<div className="flex items-center gap-3 rounded-2xl border border-accent/30 bg-accent/5 px-4 py-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-accent/20 text-accent">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
<rect x="3" y="4" width="18" height="14" rx="2" />
<path d="M10 9l5 3-5 3V9z" fill="currentColor" stroke="none" />
</svg>
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-fg">Watch Together</div>
<div className="text-xs text-fg-muted">YouTube synchronisiert ansehen</div>
</div>
<button
type="button"
onClick={() => {
window.dispatchEvent(
new CustomEvent('chatapp:open-watch-together', { detail: { id: parsed.sessionId } }),
);
}}
disabled={!parsed.sessionId}
className="cursor-pointer rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
>
Beitreten
</button>
</div>
) : parsed.kind === 'whiteboard' ? ( ) : parsed.kind === 'whiteboard' ? (
<div className="flex items-center gap-3 rounded-2xl border border-accent/30 bg-accent/5 px-4 py-3"> <div className="flex items-center gap-3 rounded-2xl border border-accent/30 bg-accent/5 px-4 py-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-accent/20 text-accent"> <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-accent/20 text-accent">
@@ -5,6 +5,7 @@ import {
type DecryptedMessage, type DecryptedMessage,
type PollOption, type PollOption,
type WhiteboardPayload, type WhiteboardPayload,
type WatchTogetherPayload,
} from '@chat-app/shared/chat'; } from '@chat-app/shared/chat';
export type AttachmentBucket = 'media' | 'audio' | 'files'; export type AttachmentBucket = 'media' | 'audio' | 'files';
@@ -107,6 +108,15 @@ export function createWhiteboardPayload(whiteboardId: string): string {
return serializeMessagePayload(payload); return serializeMessagePayload(payload);
} }
export function createWatchTogetherPayload(sessionId: string): string {
const payload: WatchTogetherPayload = {
v: 1,
type: 'watch_together',
session_id: sessionId,
};
return serializeMessagePayload(payload);
}
export function summarizePollVotes( export function summarizePollVotes(
options: PollOption[], options: PollOption[],
reactions: ReactionSummaryInput[], reactions: ReactionSummaryInput[],
+114 -1
View File
@@ -46,9 +46,11 @@ import {
collectConversationAttachments, collectConversationAttachments,
createPollPayload, createPollPayload,
createWhiteboardPayload, createWhiteboardPayload,
createWatchTogetherPayload,
} from '../lib/conversationFeatures'; } from '../lib/conversationFeatures';
import { WhiteboardModal } from '../components/WhiteboardModal'; import { WhiteboardModal } from '../components/WhiteboardModal';
import { createWhiteboard } from '@chat-app/shared/chat'; import { WatchTogetherModal } from '../components/WatchTogetherModal';
import { createWhiteboard, createWatchSession, parseYouTubeUrl } from '@chat-app/shared/chat';
import { compressImages } from '../lib/imageCompress'; import { compressImages } from '../lib/imageCompress';
import { ensureInstallId } from '../lib/installId'; import { ensureInstallId } from '../lib/installId';
import { searchCachedMessages } from '../lib/messageCache'; import { searchCachedMessages } from '../lib/messageCache';
@@ -169,6 +171,11 @@ export function ConversationPage() {
const [pollSending, setPollSending] = useState(false); const [pollSending, setPollSending] = useState(false);
const [openWhiteboardId, setOpenWhiteboardId] = useState<string | null>(null); const [openWhiteboardId, setOpenWhiteboardId] = useState<string | null>(null);
const [creatingWhiteboard, setCreatingWhiteboard] = useState(false); const [creatingWhiteboard, setCreatingWhiteboard] = useState(false);
const [openWatchSessionId, setOpenWatchSessionId] = useState<string | null>(null);
const [watchDialogOpen, setWatchDialogOpen] = useState(false);
const [watchUrl, setWatchUrl] = useState('');
const [watchError, setWatchError] = useState<string | null>(null);
const [watchCreating, setWatchCreating] = useState(false);
const [pollError, setPollError] = useState<string | null>(null); const [pollError, setPollError] = useState<string | null>(null);
const [replyTo, setReplyTo] = useState<DecryptedMessage | null>(null); const [replyTo, setReplyTo] = useState<DecryptedMessage | null>(null);
const [forwardTarget, setForwardTarget] = useState<DecryptedMessage | null>(null); const [forwardTarget, setForwardTarget] = useState<DecryptedMessage | null>(null);
@@ -472,6 +479,15 @@ export function ConversationPage() {
return () => window.removeEventListener('chatapp:open-whiteboard', onOpen); return () => window.removeEventListener('chatapp:open-whiteboard', onOpen);
}, []); }, []);
useEffect(() => {
const onOpen = (e: Event) => {
const detail = (e as CustomEvent<{ id?: string }>).detail;
if (detail?.id) setOpenWatchSessionId(detail.id);
};
window.addEventListener('chatapp:open-watch-together', onOpen);
return () => window.removeEventListener('chatapp:open-watch-together', onOpen);
}, []);
useEffect(() => { useEffect(() => {
if (id && messages.length > 0) markRead(id); if (id && messages.length > 0) markRead(id);
}, [id, messages.length, markRead]); }, [id, messages.length, markRead]);
@@ -622,6 +638,31 @@ export function ConversationPage() {
} }
}, [id, creatingWhiteboard, send, replyTo?.id]); }, [id, creatingWhiteboard, send, replyTo?.id]);
const handleStartWatchTogether = useCallback(async () => {
if (!id) return;
const videoId = parseYouTubeUrl(watchUrl);
if (!videoId) {
setWatchError('Ungültige YouTube-URL.');
return;
}
setWatchCreating(true);
setWatchError(null);
try {
const ws = await createWatchSession(supabase, { conversationId: id, videoId });
const payload = createWatchTogetherPayload(ws.id);
await send(payload, [], replyTo?.id ?? null);
setReplyTo(null);
setStickToBottom(true);
setWatchDialogOpen(false);
setWatchUrl('');
setOpenWatchSessionId(ws.id);
} catch (err: unknown) {
setWatchError(err instanceof Error ? err.message : 'Konnte Watch-Together nicht starten');
} finally {
setWatchCreating(false);
}
}, [id, watchUrl, send, replyTo?.id]);
async function ingestFiles(files: File[]) { async function ingestFiles(files: File[]) {
const compressed = await compressImages(files); const compressed = await compressImages(files);
const next: File[] = []; const next: File[] = [];
@@ -1025,6 +1066,15 @@ export function ConversationPage() {
> >
<WhiteboardIcon className="h-4 w-4" /> <WhiteboardIcon className="h-4 w-4" />
</button> </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>
<div className="relative"> <div className="relative">
<button <button
type="button" type="button"
@@ -1231,6 +1281,59 @@ export function ConversationPage() {
onClose={() => setOpenWhiteboardId(null)} onClose={() => setOpenWhiteboardId(null)}
/> />
)} )}
{openWatchSessionId && (
<WatchTogetherModal
sessionId={openWatchSessionId}
onClose={() => setOpenWatchSessionId(null)}
/>
)}
{watchDialogOpen && (
<div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-[70] flex items-center justify-center bg-ink-950/90 p-6"
onClick={(e) => {
if (e.target === e.currentTarget) setWatchDialogOpen(false);
}}
>
<div className="w-full max-w-md rounded-2xl border border-line bg-surface-2 p-5 shadow-xl">
<h2 className="mb-3 font-display text-lg font-semibold text-fg">
{t('app:watch.dialog_title', { defaultValue: 'Watch Together starten' })}
</h2>
<input
type="url"
placeholder="https://youtu.be/..."
value={watchUrl}
onChange={(e) => { setWatchUrl(e.target.value); setWatchError(null); }}
className="mb-2 w-full rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/40"
/>
{watchError && (
<p className="mb-2 text-xs text-rose-400">{watchError}</p>
)}
<div className="mt-3 flex items-center justify-end gap-2">
<button
type="button"
onClick={() => { setWatchDialogOpen(false); setWatchUrl(''); setWatchError(null); }}
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-1.5 text-xs font-medium text-fg-muted transition hover:bg-surface"
>
{t('app:watch.cancel', { defaultValue: 'Abbrechen' })}
</button>
<button
type="button"
onClick={() => void handleStartWatchTogether()}
disabled={watchCreating || !watchUrl.trim()}
className="cursor-pointer rounded-md bg-accent px-4 py-1.5 text-xs font-semibold text-accent-fg transition hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{watchCreating
? t('app:watch.starting', { defaultValue: 'Startet…' })
: t('app:watch.start', { defaultValue: 'Starten' })}
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }
@@ -1520,3 +1623,13 @@ function WhiteboardIcon(props: React.SVGProps<SVGSVGElement>) {
</svg> </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>
);
}