terry-staging #16

Merged
terry merged 96 commits from terry-staging into main 2026-06-05 16:33:12 +00:00
4 changed files with 141 additions and 42 deletions
Showing only changes of commit 2ef26390be - Show all commits

View 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.

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>
);
}