feat(stream): bubble footer with timestamp and inline favorite/download

Match the Figma 4206-6509 card layout for /browse: every bubble now
renders a bottom row with the publish timestamp on the left and the
action buttons on the right. Image, album, video, text and link cards
show only the FavoriteButton; file-document cards show the
FavoriteButton plus a new BubbleAttachmentDownloadButton sized to
match. Removes the absolute-positioned favorite from the default
variant, drops the right-aligned timestamp block, and strips the inline
per-row download button from FileDocBubble's default variant since the
download now lives in the footer. The 'latest' masonry variant is
untouched so the home page continues to use LatestFileCard's existing
internal footer.
This commit is contained in:
TerryM
2026-06-03 21:20:53 +08:00
parent 6800a8e9b6
commit 2ef26390be
4 changed files with 141 additions and 42 deletions

View File

@@ -0,0 +1,67 @@
import { LoaderCircle } from "lucide-react";
import { useState } from "react";
import { DownloadCloudIcon } from "../icons/DownloadCloudIcon";
import { useI18n } from "../../i18n";
import {
mediaSaveKindFromAttachment,
useSaveToAlbumGuide,
} from "../SaveToAlbumGuide";
import { useToast } from "../Toast";
import type { Attachment } from "../../types/post";
import { downloadAttachment, pauseActiveVideos } from "./utils/downloadFile";
import { filenameWithExtension } from "./utils/filenameDisplay";
export function BubbleAttachmentDownloadButton({
postId,
attachment,
}: {
postId: string;
attachment: Attachment;
}) {
const { t } = useI18n();
const { showToast } = useToast();
const { showSaveToAlbumGuide } = useSaveToAlbumGuide();
const [isDownloading, setIsDownloading] = useState(false);
const displayFilename = filenameWithExtension(
attachment.filename,
attachment.mime,
);
const handleDownload = async () => {
if (isDownloading) return;
pauseActiveVideos();
setIsDownloading(true);
try {
await downloadAttachment(postId, attachment.id, displayFilename);
const mediaKind = mediaSaveKindFromAttachment(attachment);
if (mediaKind) showSaveToAlbumGuide(mediaKind);
} catch {
showToast(t("downloadFail"), "error");
} finally {
setIsDownloading(false);
}
};
return (
<button
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void handleDownload();
}}
disabled={isDownloading}
aria-label={
isDownloading ? t("downloading") : `Download ${attachment.filename}`
}
aria-busy={isDownloading}
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-white/10 bg-[#191921]/90 text-[#A8A9AE] outline-none transition active:scale-95 hover:border-ark-gold hover:bg-[#191921] hover:text-ark-gold focus-visible:ring-2 focus-visible:ring-ark-gold/80 disabled:cursor-wait disabled:opacity-70"
>
{isDownloading ? (
<LoaderCircle className="h-4 w-4 animate-spin" strokeWidth={2.2} />
) : (
<DownloadCloudIcon className="h-4 w-4" />
)}
</button>
);
}

View File

@@ -9,6 +9,7 @@ import { VideoBubble } from "./bubbles/VideoBubble";
import { LinkPreviewCard } from "./LinkPreviewCard";
import { formatDateTime } from "./utils/formatTime";
import { FavoriteButton } from "../../favorites/FavoriteButton";
import { BubbleAttachmentDownloadButton } from "./BubbleAttachmentDownloadButton";
export type MessageBubbleVariant = "default" | "latest";
@@ -47,7 +48,9 @@ export function MessageBubble({
Bubble === VideoBubble ||
Bubble === ImageBubble ||
Bubble === ImageWithTextBubble;
const isLatestFileCard = variant === "latest" && Bubble === FileDocBubble;
const isFileBubble = Bubble === FileDocBubble;
const isLatestVariant = variant === "latest";
const isLatestFileCard = isLatestVariant && isFileBubble;
return (
<div
@@ -63,22 +66,47 @@ export function MessageBubble({
isVisual || isLatestFileCard ? "p-0" : "px-4 py-3"
}`}
>
{!isLatestFileCard ? (
{isLatestVariant && !isFileBubble ? (
<FavoriteButton
resourceId={post.id}
size="sm"
className={`absolute z-20 shadow-lg shadow-black/30 ${
variant === "latest" ? "bottom-4 right-4" : "right-3 top-3"
}`}
className="absolute z-20 bottom-4 right-4 shadow-lg shadow-black/30"
/>
) : null}
<Bubble post={post} variant={variant} />
{post.linkPreview ? (
<div className={isVisual ? "px-4 pt-3" : "mt-3"}>
<LinkPreviewCard preview={post.linkPreview} />
</div>
) : null}
{!isLatestFileCard ? (
{!isLatestVariant ? (
<div
className={`flex items-center justify-between gap-3 ${
isVisual ? "px-4 pb-3 pt-3" : "mt-3"
}`}
>
<time
dateTime={post.publishedAt}
className="min-w-0 truncate text-[12px] leading-[19px] text-[#A8A9AE]"
>
{formatDateTime(post.publishedAt)}
</time>
<div className="flex shrink-0 items-center gap-2">
<FavoriteButton resourceId={post.id} size="sm" />
{isFileBubble && post.attachments[0] ? (
<BubbleAttachmentDownloadButton
postId={post.id}
attachment={post.attachments[0]}
/>
) : null}
</div>
</div>
) : null}
{isLatestVariant && !isFileBubble ? (
<time
dateTime={post.publishedAt}
className={`block text-right text-[12px] leading-[19px] text-[#A8A9AE] ${

View File

@@ -18,29 +18,11 @@ import { useToast } from "../../Toast";
import { FavoriteButton } from "../../../favorites/FavoriteButton";
import type { MessageBubbleVariant } from "../MessageBubble";
function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) {
const { t } = useI18n();
const { showToast } = useToast();
const { showSaveToAlbumGuide } = useSaveToAlbumGuide();
function AttachmentRow({ att }: { postId: string; att: Attachment }) {
const { Icon, color } = fileIcon({ mime: att.mime, filename: att.filename });
const displayFilename = filenameWithExtension(att.filename, att.mime);
const [isDownloading, setIsDownloading] = useState(false);
const [previewFailed, setPreviewFailed] = useState(false);
const handleDownload = async () => {
if (isDownloading) return;
setIsDownloading(true);
try {
await downloadAttachment(postId, att.id, displayFilename);
const mediaKind = mediaSaveKindFromAttachment(att);
if (mediaKind) showSaveToAlbumGuide(mediaKind);
} catch {
showToast(t("downloadFail"), "error");
} finally {
setIsDownloading(false);
}
};
const isImage = att.kind === "image" || att.mime.startsWith("image/");
const previewUrl =
att.thumbnailUrl ?? att.posterUrl ?? (isImage ? att.url : undefined);
@@ -84,25 +66,9 @@ function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) {
})()}
</div>
<div className="text-[12px] font-medium leading-[19.2px] text-[#A8A9AE]">
{isDownloading ? t("downloading") : formatBytes(att.sizeBytes)}
{formatBytes(att.sizeBytes)}
</div>
</div>
<button
type="button"
onClick={handleDownload}
disabled={isDownloading}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#191921] text-white transition hover:bg-[#22232D] disabled:cursor-wait"
aria-label={
isDownloading ? t("downloading") : `Download ${att.filename}`
}
aria-busy={isDownloading}
>
{isDownloading ? (
<LoaderCircle className="h-5 w-5 animate-spin" strokeWidth={2.3} />
) : (
<DownloadCloudIcon className="h-6 w-6" />
)}
</button>
</div>
);
}