terry-staging #16
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