diff --git a/src/components/RecommendedCard.tsx b/src/components/RecommendedCard.tsx
index 6e02a58..84434db 100644
--- a/src/components/RecommendedCard.tsx
+++ b/src/components/RecommendedCard.tsx
@@ -1,9 +1,9 @@
-import { Download } from "lucide-react";
+import { Download, LoaderCircle } from "lucide-react";
import { Link } from "react-router-dom";
import type { Resource } from "../api";
import { assetUrl, postJSON } from "../api";
import { useI18n } from "../i18n";
-import { useMemo } from "react";
+import { useMemo, useState } from "react";
import { formatDateYmd } from "../utils/format";
import { officialRecommendationCoverFallbacks } from "./FigmaBanner";
import {
@@ -31,6 +31,7 @@ export function RecommendedCard({
visualIndex?: number;
}) {
const { t } = useI18n();
+ const [isDownloading, setIsDownloading] = useState(false);
const cover = useMemo(() => {
const original = r.coverImage || r.previewUrl;
if (isPlaceholderAsset(original)) {
@@ -85,29 +86,46 @@ export function RecommendedCard({
{dl ? (
) : null}
diff --git a/src/components/messageStream/bubbles/FileDocBubble.tsx b/src/components/messageStream/bubbles/FileDocBubble.tsx
index c74498f..7b63ea3 100644
--- a/src/components/messageStream/bubbles/FileDocBubble.tsx
+++ b/src/components/messageStream/bubbles/FileDocBubble.tsx
@@ -1,4 +1,5 @@
-import { ArrowDownToLine } from "lucide-react";
+import { ArrowDownToLine, LoaderCircle } from "lucide-react";
+import { useState } from "react";
import { useI18n } from "../../../i18n";
import type { Attachment, Post } from "../../../types/post";
import { downloadAttachment } from "../utils/downloadFile";
@@ -11,12 +12,18 @@ import { formatBytes } from "../utils/formatBytes";
import { postDisplayText } from "../utils/postText";
function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) {
+ const { t } = useI18n();
const isImageAsDoc = att.mime.startsWith("image/");
const { Icon, color } = fileIcon({ mime: att.mime, filename: att.filename });
const displayFilename = filenameWithExtension(att.filename, att.mime);
+ const [isDownloading, setIsDownloading] = useState(false);
const handleDownload = () => {
- void downloadAttachment(postId, att.id, displayFilename).catch(() => {});
+ if (isDownloading) return;
+ setIsDownloading(true);
+ void downloadAttachment(postId, att.id, displayFilename)
+ .finally(() => setIsDownloading(false))
+ .catch(() => {});
};
return (
@@ -24,8 +31,12 @@ function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) {
@@ -53,7 +68,7 @@ function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) {
{middleEllipsisFilename(displayFilename)}
- {formatBytes(att.sizeBytes)}
+ {isDownloading ? t("downloading") : formatBytes(att.sizeBytes)}
diff --git a/src/components/messageStream/bubbles/ImageWithTextBubble.tsx b/src/components/messageStream/bubbles/ImageWithTextBubble.tsx
index f072cff..8ec7af8 100644
--- a/src/components/messageStream/bubbles/ImageWithTextBubble.tsx
+++ b/src/components/messageStream/bubbles/ImageWithTextBubble.tsx
@@ -1,12 +1,17 @@
+import { ArrowDownToLine, LoaderCircle } from "lucide-react";
+import { useState } from "react";
import { useI18n } from "../../../i18n";
import type { 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";
export function ImageWithTextBubble({ post }: { post: Post }) {
const { openLightbox } = useLightbox();
- const { lang } = useI18n();
+ const { lang, t } = useI18n();
+ const [isDownloading, setIsDownloading] = useState(false);
const att = post.attachments[0];
const text = postDisplayText(post, lang);
if (!att) return null;
@@ -29,6 +34,34 @@ export function ImageWithTextBubble({ post }: { post: Post }) {
style={{ aspectRatio: ratio }}
/>
+
{text ? (
diff --git a/src/components/messageStream/bubbles/VideoBubble.tsx b/src/components/messageStream/bubbles/VideoBubble.tsx
index 24611f7..81413ee 100644
--- a/src/components/messageStream/bubbles/VideoBubble.tsx
+++ b/src/components/messageStream/bubbles/VideoBubble.tsx
@@ -1,4 +1,4 @@
-import { ArrowDownToLine, Play } from "lucide-react";
+import { ArrowDownToLine, LoaderCircle, Play } from "lucide-react";
import { useRef, useState } from "react";
import { useI18n } from "../../../i18n";
import type { Post } from "../../../types/post";
@@ -17,9 +17,10 @@ function formatDuration(sec: number | undefined): string {
export function VideoBubble({ post }: { post: Post }) {
const { openVideo } = useVideoPlayer();
- const { lang } = useI18n();
+ const { lang, t } = useI18n();
const att = post.attachments[0];
const [playing, setPlaying] = useState(false);
+ const [isDownloading, setIsDownloading] = useState(false);
const videoRef = useRef
(null);
const text = postDisplayText(post, lang);
if (!att) return null;
@@ -73,24 +74,43 @@ export function VideoBubble({ post }: { post: Post }) {
type="button"
onClick={(e) => {
e.stopPropagation();
- void downloadAttachment(post.id, att.id, att.filename).catch(
- () => {},
- );
+ if (isDownloading) return;
+ setIsDownloading(true);
+ void downloadAttachment(post.id, att.id, att.filename)
+ .finally(() => setIsDownloading(false))
+ .catch(() => {});
}}
- className="group absolute left-3 top-3 z-10 inline-flex overflow-hidden rounded-full bg-black/45 text-xs text-white shadow-lg ring-1 ring-white/15 backdrop-blur-md transition hover:bg-black/60"
- aria-label={`Download ${att.filename}`}
+ disabled={isDownloading}
+ className="group absolute left-3 top-3 z-10 inline-flex overflow-hidden rounded-full bg-black/45 text-xs text-white shadow-lg ring-1 ring-white/15 backdrop-blur-md transition hover:bg-black/60 disabled:cursor-wait"
+ aria-label={
+ isDownloading ? t("downloading") : `Download ${att.filename}`
+ }
+ aria-busy={isDownloading}
>
-
+ {isDownloading ? (
+
+ ) : (
+
+ )}
- {duration ? (
+ {isDownloading ? (
+ {t("downloading")}
+ ) : (
<>
- {duration}
- ·
+ {duration ? (
+ <>
+ {duration}
+ ·
+ >
+ ) : null}
+ {formatBytes(att.sizeBytes)}
>
- ) : null}
- {formatBytes(att.sizeBytes)}
+ )}
) : null}
@@ -78,13 +76,11 @@ function LightboxView({
images,
startIndex,
caption: captionText,
- postId,
onClose,
}: {
images: Attachment[];
startIndex: number;
caption?: string;
- postId?: string;
onClose: () => void;
}) {
const [index, setIndex] = useState(startIndex);
@@ -137,24 +133,6 @@ function LightboxView({
-
-
{images.length > 1 ? (
<>