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:
38
.unipi/docs/quick-work/2026-06-03-bubble-footer-actions.md
Normal file
38
.unipi/docs/quick-work/2026-06-03-bubble-footer-actions.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
title: "MessageBubble footer — timestamp + favorite + (file) download"
|
||||||
|
type: quick-work
|
||||||
|
date: 2026-06-03
|
||||||
|
---
|
||||||
|
|
||||||
|
# MessageBubble footer — timestamp + favorite + (file) download
|
||||||
|
|
||||||
|
## Task
|
||||||
|
Implement the 全部资料 card layout from Figma `4206-6509`:
|
||||||
|
- Each card shows a bottom row with the publish timestamp on the left and action buttons on the right.
|
||||||
|
- Image / album / video / text / link bubbles → 1 button (FavoriteButton).
|
||||||
|
- File-document bubbles (mp3, pptx, pdf, zip, …) → 2 buttons (FavoriteButton + Download).
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
- `src/components/messageStream/BubbleAttachmentDownloadButton.tsx` (new) — small circular download button visually matched to `FavoriteButton` (sm). Handles its own download/loading state and surfaces the `SaveToAlbumGuide` toast for media kinds.
|
||||||
|
- `src/components/messageStream/MessageBubble.tsx`
|
||||||
|
- Removed the absolute-positioned FavoriteButton for the default variant.
|
||||||
|
- Removed the right-aligned `<time>` block for the default variant.
|
||||||
|
- Added a new flex footer: timestamp on the left, FavoriteButton (+ optional `BubbleAttachmentDownloadButton`) on the right.
|
||||||
|
- File-doc detection is based on `pickBubble(post) === FileDocBubble` and the primary attachment `post.attachments[0]`.
|
||||||
|
- `variant === "latest"` paths are left untouched (latest masonry cards keep the bottom-right absolute FavoriteButton and the existing right-aligned timestamp because `LatestFileCard` already renders its own footer).
|
||||||
|
- `src/components/messageStream/bubbles/FileDocBubble.tsx`
|
||||||
|
- Removed the inline per-row download button from `AttachmentRow` in the default variant (download now lives in the bubble footer).
|
||||||
|
- Trimmed the now-unused state and handlers from `AttachmentRow`; imports remain because `LatestFileCard` still uses them.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- `npx tsc --noEmit` — clean.
|
||||||
|
- `npm run format` then `npm run format:check` — clean.
|
||||||
|
- `npm test` — 49/49 passing.
|
||||||
|
- Visual check pending on device — expected to match Figma `4206-6509`:
|
||||||
|
- timestamp + bookmark on image/album/video/text/link cards
|
||||||
|
- timestamp + bookmark + download on file cards
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- For posts with multiple file attachments, the footer download button currently targets `attachments[0]` only (matches the Figma single-attachment cards). If a multi-attachment file post needs per-attachment download, revisit `AttachmentRow` and re-add a small inline download or expose a list in an overflow menu.
|
||||||
|
- The new download button mirrors `FavoriteButton`'s sm style (h-9 w-9, same border / bg / hover treatment) so the two sit on the same baseline and share visual weight.
|
||||||
|
- The home page's "latest" masonry variant is unaffected — that path renders `LatestFileCard` which already has its own footer.
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { VideoBubble } from "./bubbles/VideoBubble";
|
|||||||
import { LinkPreviewCard } from "./LinkPreviewCard";
|
import { LinkPreviewCard } from "./LinkPreviewCard";
|
||||||
import { formatDateTime } from "./utils/formatTime";
|
import { formatDateTime } from "./utils/formatTime";
|
||||||
import { FavoriteButton } from "../../favorites/FavoriteButton";
|
import { FavoriteButton } from "../../favorites/FavoriteButton";
|
||||||
|
import { BubbleAttachmentDownloadButton } from "./BubbleAttachmentDownloadButton";
|
||||||
|
|
||||||
export type MessageBubbleVariant = "default" | "latest";
|
export type MessageBubbleVariant = "default" | "latest";
|
||||||
|
|
||||||
@@ -47,7 +48,9 @@ export function MessageBubble({
|
|||||||
Bubble === VideoBubble ||
|
Bubble === VideoBubble ||
|
||||||
Bubble === ImageBubble ||
|
Bubble === ImageBubble ||
|
||||||
Bubble === ImageWithTextBubble;
|
Bubble === ImageWithTextBubble;
|
||||||
const isLatestFileCard = variant === "latest" && Bubble === FileDocBubble;
|
const isFileBubble = Bubble === FileDocBubble;
|
||||||
|
const isLatestVariant = variant === "latest";
|
||||||
|
const isLatestFileCard = isLatestVariant && isFileBubble;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -63,22 +66,47 @@ export function MessageBubble({
|
|||||||
isVisual || isLatestFileCard ? "p-0" : "px-4 py-3"
|
isVisual || isLatestFileCard ? "p-0" : "px-4 py-3"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{!isLatestFileCard ? (
|
{isLatestVariant && !isFileBubble ? (
|
||||||
<FavoriteButton
|
<FavoriteButton
|
||||||
resourceId={post.id}
|
resourceId={post.id}
|
||||||
size="sm"
|
size="sm"
|
||||||
className={`absolute z-20 shadow-lg shadow-black/30 ${
|
className="absolute z-20 bottom-4 right-4 shadow-lg shadow-black/30"
|
||||||
variant === "latest" ? "bottom-4 right-4" : "right-3 top-3"
|
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Bubble post={post} variant={variant} />
|
<Bubble post={post} variant={variant} />
|
||||||
|
|
||||||
{post.linkPreview ? (
|
{post.linkPreview ? (
|
||||||
<div className={isVisual ? "px-4 pt-3" : "mt-3"}>
|
<div className={isVisual ? "px-4 pt-3" : "mt-3"}>
|
||||||
<LinkPreviewCard preview={post.linkPreview} />
|
<LinkPreviewCard preview={post.linkPreview} />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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
|
<time
|
||||||
dateTime={post.publishedAt}
|
dateTime={post.publishedAt}
|
||||||
className={`block text-right text-[12px] leading-[19px] text-[#A8A9AE] ${
|
className={`block text-right text-[12px] leading-[19px] text-[#A8A9AE] ${
|
||||||
|
|||||||
@@ -18,29 +18,11 @@ import { useToast } from "../../Toast";
|
|||||||
import { FavoriteButton } from "../../../favorites/FavoriteButton";
|
import { FavoriteButton } from "../../../favorites/FavoriteButton";
|
||||||
import type { MessageBubbleVariant } from "../MessageBubble";
|
import type { MessageBubbleVariant } from "../MessageBubble";
|
||||||
|
|
||||||
function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) {
|
function AttachmentRow({ att }: { postId: string; att: Attachment }) {
|
||||||
const { t } = useI18n();
|
|
||||||
const { showToast } = useToast();
|
|
||||||
const { showSaveToAlbumGuide } = useSaveToAlbumGuide();
|
|
||||||
const { Icon, color } = fileIcon({ mime: att.mime, filename: att.filename });
|
const { Icon, color } = fileIcon({ mime: att.mime, filename: att.filename });
|
||||||
const displayFilename = filenameWithExtension(att.filename, att.mime);
|
const displayFilename = filenameWithExtension(att.filename, att.mime);
|
||||||
const [isDownloading, setIsDownloading] = useState(false);
|
|
||||||
const [previewFailed, setPreviewFailed] = 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 isImage = att.kind === "image" || att.mime.startsWith("image/");
|
||||||
const previewUrl =
|
const previewUrl =
|
||||||
att.thumbnailUrl ?? att.posterUrl ?? (isImage ? att.url : undefined);
|
att.thumbnailUrl ?? att.posterUrl ?? (isImage ? att.url : undefined);
|
||||||
@@ -84,25 +66,9 @@ function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) {
|
|||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[12px] font-medium leading-[19.2px] text-[#A8A9AE]">
|
<div className="text-[12px] font-medium leading-[19.2px] text-[#A8A9AE]">
|
||||||
{isDownloading ? t("downloading") : formatBytes(att.sizeBytes)}
|
{formatBytes(att.sizeBytes)}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user