feat: improve chat media and notification interactions
Some checks failed
Android CI / android (push) Failing after 4m12s
Android Release / release (push) Has started running
CI / test (push) Has started running

fix: reveal spoiler text on tap with animated transitions

fix: render and play circle videos correctly on web

feat: add quick reply and mark-as-read notification actions

fix: stabilize circle recording gestures and chat auxiliary error handling
This commit is contained in:
2026-04-05 14:48:36 +03:00
parent 2dcd1ba129
commit e8f9efb108
10 changed files with 465 additions and 204 deletions

View File

@@ -1116,36 +1116,49 @@ function renderMessageContent(
if (mediaItems.length === 1) {
const item = mediaItems[0];
const blockViewerOpen = isStickerOrGifMedia(item.url);
const isInteractiveCircle = isCircleVideo && item.type === "video";
return (
<div className="space-y-1.5">
<button
className={`relative block overflow-hidden bg-slate-950/30 ${isCircleVideo ? "aspect-square w-56 rounded-full" : "rounded-xl"}`}
onClick={() => {
if (blockViewerOpen || isCircleVideo) {
return;
}
opts.onOpenMedia(item.url, item.type);
}}
onContextMenu={(event) => {
event.stopPropagation();
opts.onAttachmentContextMenu(event, item.url);
}}
type="button"
>
{item.type === "image" ? (
<img
alt="attachment"
className={isCircleVideo ? "h-full w-full object-cover" : "max-h-80 rounded-xl object-cover"}
draggable={false}
src={item.url}
/>
) : (
<>
<video className={isCircleVideo ? "h-full w-full object-cover" : "max-h-80 rounded-xl"} muted src={item.url} />
<span className="absolute inset-0 flex items-center justify-center text-3xl text-white/85"></span>
</>
)}
</button>
{isInteractiveCircle ? (
<div
className="relative block aspect-square w-56 overflow-hidden rounded-full bg-slate-950/30"
onContextMenu={(event) => {
event.stopPropagation();
opts.onAttachmentContextMenu(event, item.url);
}}
>
<CircleVideoInlinePlayer src={item.url} />
</div>
) : (
<button
className={`relative block overflow-hidden bg-slate-950/30 ${isCircleVideo ? "aspect-square w-56 rounded-full" : "rounded-xl"}`}
onClick={() => {
if (blockViewerOpen || isCircleVideo) {
return;
}
opts.onOpenMedia(item.url, item.type);
}}
onContextMenu={(event) => {
event.stopPropagation();
opts.onAttachmentContextMenu(event, item.url);
}}
type="button"
>
{item.type === "image" ? (
<img
alt="attachment"
className={isCircleVideo ? "h-full w-full object-cover" : "max-h-80 rounded-xl object-cover"}
draggable={false}
src={item.url}
/>
) : (
<>
<video className={isCircleVideo ? "h-full w-full object-cover" : "max-h-80 rounded-xl"} muted src={item.url} />
<span className="absolute inset-0 flex items-center justify-center text-3xl text-white/85"></span>
</>
)}
</button>
)}
{captionText ? (
<p className="whitespace-pre-wrap break-words text-sm" dangerouslySetInnerHTML={{ __html: formatMessageHtml(captionText) }} />
) : null}
@@ -1500,6 +1513,71 @@ async function downloadFileFromUrl(url: string): Promise<void> {
window.URL.revokeObjectURL(blobUrl);
}
function CircleVideoInlinePlayer({ src }: { src: string }) {
const videoRef = useRef<HTMLVideoElement | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
useEffect(() => {
const video = videoRef.current;
if (!video) {
return;
}
const handlePlay = () => setIsPlaying(true);
const handlePause = () => setIsPlaying(false);
const handleEnded = () => {
setIsPlaying(false);
video.currentTime = 0;
};
video.addEventListener("play", handlePlay);
video.addEventListener("pause", handlePause);
video.addEventListener("ended", handleEnded);
return () => {
video.removeEventListener("play", handlePlay);
video.removeEventListener("pause", handlePause);
video.removeEventListener("ended", handleEnded);
};
}, [src]);
async function togglePlayback() {
const video = videoRef.current;
if (!video) {
return;
}
if (video.paused || video.ended) {
try {
await video.play();
} catch {
setIsPlaying(false);
}
return;
}
video.pause();
}
return (
<div className="relative h-full w-full">
<video
ref={videoRef}
className="h-full w-full object-cover"
playsInline
preload="metadata"
src={src}
/>
<span className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-black/45 text-2xl text-white">
{isPlaying ? "❚❚" : "▶"}
</span>
</span>
<button
aria-label={isPlaying ? "Pause video message" : "Play video message"}
className="absolute inset-0"
onClick={() => void togglePlayback()}
type="button"
/>
</div>
);
}
function AudioInlinePlayer({ src, title }: { src: string; title: string }) {
const track = useAudioPlayerStore((s) => s.track);
const isPlayingGlobal = useAudioPlayerStore((s) => s.isPlaying);