diff --git a/src/components/messageStream/bubbles/AlbumBubble.tsx b/src/components/messageStream/bubbles/AlbumBubble.tsx
index 0040cc5..af4d934 100644
--- a/src/components/messageStream/bubbles/AlbumBubble.tsx
+++ b/src/components/messageStream/bubbles/AlbumBubble.tsx
@@ -1,7 +1,12 @@
+import { ArrowDownToLine, LoaderCircle, X } from "lucide-react";
+import { useEffect, useState } from "react";
+import { createPortal } from "react-dom";
import { useI18n } from "../../../i18n";
import type { Attachment, Post } from "../../../types/post";
import { useLightbox } from "../overlays/ImageLightbox";
import { autolink } from "../utils/autolink";
+import { downloadAttachment } from "../utils/downloadFile";
+import { formatBytes } from "../utils/formatBytes";
import { postDisplayText } from "../utils/postText";
const MAX_VISIBLE = 4;
@@ -10,9 +15,130 @@ function imageRatio(att: Attachment) {
return att.width && att.height ? `${att.width} / ${att.height}` : "4 / 3";
}
+function ImageListDownloadButton({
+ postId,
+ attachment,
+}: {
+ postId: string;
+ attachment: Attachment;
+}) {
+ const { t } = useI18n();
+ const [isDownloading, setIsDownloading] = useState(false);
+
+ const handleDownload = () => {
+ if (isDownloading) return;
+ setIsDownloading(true);
+ void downloadAttachment(postId, attachment.id, attachment.filename)
+ .finally(() => setIsDownloading(false))
+ .catch(() => {});
+ };
+
+ return (
+
+ );
+}
+
+function ImageListDialog({
+ postId,
+ images,
+ onClose,
+ onPick,
+}: {
+ postId: string;
+ images: Attachment[];
+ onClose: () => void;
+ onPick: (index: number) => void;
+}) {
+ useEffect(() => {
+ const onKey = (event: KeyboardEvent) => {
+ if (event.key === "Escape") onClose();
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [onClose]);
+
+ return createPortal(
+
+
e.stopPropagation()}
+ >
+
+
+ {images.map((image, index) => (
+
+
+
+
+ ))}
+
+
+
,
+ document.body,
+ );
+}
+
export function AlbumBubble({ post }: { post: Post }) {
const { openLightbox } = useLightbox();
const { lang } = useI18n();
+ const [listOpen, setListOpen] = useState(false);
const images = post.attachments;
const text = postDisplayText(post, lang);
const shouldMerge = images.length > MAX_VISIBLE;
@@ -58,9 +184,12 @@ export function AlbumBubble({ post }: { post: Post }) {
@@ -140,8 +145,156 @@ function VideoAttachmentCard({
);
}
+function AttachmentListDownloadButton({
+ postId,
+ attachment,
+}: {
+ postId: string;
+ attachment: Attachment;
+}) {
+ const { t } = useI18n();
+ const [isDownloading, setIsDownloading] = useState(false);
+
+ const handleDownload = () => {
+ if (isDownloading) return;
+ setIsDownloading(true);
+ void downloadAttachment(postId, attachment.id, attachment.filename)
+ .finally(() => setIsDownloading(false))
+ .catch(() => {});
+ };
+
+ return (
+
+ );
+}
+
+function VideoListDialog({
+ postId,
+ videos,
+ onClose,
+ onPick,
+}: {
+ postId: string;
+ videos: Attachment[];
+ onClose: () => void;
+ onPick: (attachment: Attachment) => void;
+}) {
+ useEffect(() => {
+ const onKey = (event: KeyboardEvent) => {
+ if (event.key === "Escape") onClose();
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [onClose]);
+
+ return createPortal(
+
+
e.stopPropagation()}
+ >
+
+
+ {videos.map((video, index) => {
+ const thumb = video.posterUrl ?? video.thumbnailUrl;
+ const previewVideoUrl = video.url.includes("#")
+ ? video.url
+ : `${video.url}#t=0.1`;
+ const duration = formatDuration(video.durationSec);
+ return (
+
+
+
+
+ );
+ })}
+
+
+
,
+ document.body,
+ );
+}
+
export function VideoBubble({ post }: { post: Post }) {
const { lang } = useI18n();
+ const { openVideo } = useVideoPlayer();
+ const [listOpen, setListOpen] = useState(false);
const videos = post.attachments.filter(isVideoAttachment);
const text = postDisplayText(post, lang);
const shouldMerge = videos.length > MAX_VISIBLE;
@@ -163,6 +316,7 @@ export function VideoBubble({ post }: { post: Post }) {
attachment={att}
compact
overlayCount={isLastSlot ? extra : undefined}
+ onMoreClick={() => setListOpen(true)}
/>
);
})}
@@ -172,6 +326,17 @@ export function VideoBubble({ post }: { post: Post }) {
{autolink(text)}
) : null}
+ {listOpen ? (
+ setListOpen(false)}
+ onPick={(att) => {
+ setListOpen(false);
+ openVideo(att, 0);
+ }}
+ />
+ ) : null}
);
}