Commit 34c90dd5 authored by boojack's avatar boojack

chore: remove duplicate tags from share image preview

parent 0fb83a74
import { timestampDate } from "@bufbuild/protobuf/wkt";
import { forwardRef, useMemo } from "react"; import { forwardRef, useMemo } from "react";
import MemoContent from "@/components/MemoContent"; import MemoContent from "@/components/MemoContent";
import { separateAttachments } from "@/components/MemoMetadata/Attachment/attachmentHelpers";
import UserAvatar from "@/components/UserAvatar"; import UserAvatar from "@/components/UserAvatar";
import i18n from "@/i18n"; import i18n from "@/i18n";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import { buildAttachmentVisualItems, countLogicalAttachmentItems } from "@/utils/media-item";
import { useMemoViewContext } from "../MemoView/MemoViewContext"; import { useMemoViewContext } from "../MemoView/MemoViewContext";
import { getMemoSharePreviewAvatarUrl } from "./memoShareImage"; import { buildMemoShareImagePreviewModel } from "./memoShareImagePreviewModel";
const MemoShareImagePreview = forwardRef<HTMLDivElement, { width: number }>(({ width }, ref) => { const MemoShareImagePreview = forwardRef<HTMLDivElement, { width: number }>(({ width }, ref) => {
const t = useTranslate(); const t = useTranslate();
const { memo, creator, blurred, showBlurredContent } = useMemoViewContext(); const { memo, creator, blurred, showBlurredContent } = useMemoViewContext();
const fallbackDisplayName = t("common.memo");
const locale = i18n.language;
const displayName = creator?.displayName || creator?.username || t("common.memo"); const preview = useMemo(
const avatarUrl = getMemoSharePreviewAvatarUrl(creator?.avatarUrl); () =>
const displayTime = memo.displayTime ? timestampDate(memo.displayTime) : memo.createTime ? timestampDate(memo.createTime) : undefined; buildMemoShareImagePreviewModel({
const formattedDisplayTime = displayTime?.toLocaleString(i18n.language, { memo,
dateStyle: "medium", creator,
timeStyle: "short", fallbackDisplayName,
}); locale,
const { attachmentCount, nonVisualAttachmentCount, visualItems } = useMemo(() => { }),
const attachmentGroups = separateAttachments(memo.attachments); [creator, fallbackDisplayName, locale, memo],
const previewVisualItems = buildAttachmentVisualItems(attachmentGroups.visual); );
const totalAttachmentCount = countLogicalAttachmentItems(memo.attachments);
return {
attachmentCount: totalAttachmentCount,
nonVisualAttachmentCount: totalAttachmentCount - previewVisualItems.length,
visualItems: previewVisualItems,
};
}, [memo.attachments]);
return ( return (
<div ref={ref} className="overflow-hidden rounded-xl border border-border/50 bg-background p-2 sm:p-2.5" style={{ width }}> <div ref={ref} className="overflow-hidden rounded-xl border border-border/50 bg-background p-2 sm:p-2.5" style={{ width }}>
<div className="overflow-hidden rounded-lg border border-border/60 bg-background p-4 sm:p-5"> <div className="overflow-hidden rounded-lg border border-border/60 bg-background p-4 sm:p-5">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="flex min-w-0 items-center gap-2.5"> <div className="flex min-w-0 items-center gap-2.5">
<UserAvatar avatarUrl={avatarUrl} className="h-8 w-8 rounded-xl" /> <UserAvatar avatarUrl={preview.avatarUrl} className="h-8 w-8 rounded-xl" />
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-[13px] font-semibold text-foreground">{displayName}</div> <div className="truncate text-[13px] font-semibold text-foreground">{preview.displayName}</div>
{formattedDisplayTime && <div className="truncate text-xs text-muted-foreground">{formattedDisplayTime}</div>} {preview.formattedDisplayTime && <div className="truncate text-xs text-muted-foreground">{preview.formattedDisplayTime}</div>}
</div> </div>
</div> </div>
</div> </div>
...@@ -52,21 +43,21 @@ const MemoShareImagePreview = forwardRef<HTMLDivElement, { width: number }>(({ w ...@@ -52,21 +43,21 @@ const MemoShareImagePreview = forwardRef<HTMLDivElement, { width: number }>(({ w
</div> </div>
</div> </div>
{visualItems.length > 0 && ( {preview.visualItems.length > 0 && (
<div className={cn("mt-4 grid gap-1.5", visualItems.length === 1 ? "grid-cols-1" : "grid-cols-2")}> <div className={cn("mt-4 grid gap-1.5", preview.visualItems.length === 1 ? "grid-cols-1" : "grid-cols-2")}>
{visualItems.slice(0, 4).map((item, index) => ( {preview.visualItems.slice(0, 4).map((item, index) => (
<div <div
key={item.id} key={item.id}
className={cn( className={cn(
"relative overflow-hidden rounded-md border border-border/70 bg-muted/30", "relative overflow-hidden rounded-md border border-border/70 bg-muted/30",
visualItems.length === 1 ? "aspect-[4/3]" : "aspect-square", preview.visualItems.length === 1 ? "aspect-[4/3]" : "aspect-square",
visualItems.length === 3 && index === 0 && "col-span-2 aspect-[2.2/1]", preview.visualItems.length === 3 && index === 0 && "col-span-2 aspect-[2.2/1]",
)} )}
> >
<img src={item.posterUrl} alt={item.filename} className="h-full w-full object-cover" loading="eager" decoding="async" /> <img src={item.posterUrl} alt={item.filename} className="h-full w-full object-cover" loading="eager" decoding="async" />
{index === 3 && visualItems.length > 4 && ( {index === 3 && preview.visualItems.length > 4 && (
<div className="absolute inset-0 flex items-center justify-center bg-foreground/35 text-lg font-semibold text-background"> <div className="absolute inset-0 flex items-center justify-center bg-foreground/35 text-lg font-semibold text-background">
+{visualItems.length - 4} +{preview.visualItems.length - 4}
</div> </div>
)} )}
</div> </div>
...@@ -74,26 +65,16 @@ const MemoShareImagePreview = forwardRef<HTMLDivElement, { width: number }>(({ w ...@@ -74,26 +65,16 @@ const MemoShareImagePreview = forwardRef<HTMLDivElement, { width: number }>(({ w
</div> </div>
)} )}
{(memo.tags.length > 0 || nonVisualAttachmentCount > 0) && ( {preview.footerBadges.length > 0 && (
<div className="mt-4 flex flex-wrap items-center gap-1.5"> <div className="mt-4 flex flex-wrap items-center gap-1.5">
{memo.tags.slice(0, 3).map((tag) => ( {preview.footerBadges.map((badge) => (
<span <span
key={tag} key={badge.type}
className="inline-flex rounded-full border border-border/70 bg-muted/55 px-2 py-0.5 text-[11px] text-muted-foreground" className="inline-flex rounded-full border border-border/70 bg-muted/55 px-2 py-0.5 text-[11px] text-muted-foreground"
> >
#{tag} {badge.count} {t("common.attachments").toLowerCase()}
</span> </span>
))} ))}
{memo.tags.length > 3 && (
<span className="inline-flex rounded-full border border-border/70 bg-muted/55 px-2 py-0.5 text-[11px] text-muted-foreground">
+{memo.tags.length - 3}
</span>
)}
{nonVisualAttachmentCount > 0 && (
<span className="inline-flex rounded-full border border-border/70 bg-muted/55 px-2 py-0.5 text-[11px] text-muted-foreground">
{attachmentCount} {t("common.attachments").toLowerCase()}
</span>
)}
</div> </div>
)} )}
</div> </div>
......
...@@ -55,7 +55,7 @@ const waitForPreviewAssets = async (node: HTMLElement) => { ...@@ -55,7 +55,7 @@ const waitForPreviewAssets = async (node: HTMLElement) => {
}; };
export const buildMemoShareImageFileName = (memoName: string) => { export const buildMemoShareImageFileName = (memoName: string) => {
const suffix = memoName.split("/").pop() ?? "memo"; const suffix = memoName.split("/").pop() || "memo";
return `memo-${suffix}.png`; return `memo-${suffix}.png`;
}; };
......
import { timestampDate } from "@bufbuild/protobuf/wkt";
import { separateAttachments } from "@/components/MemoMetadata/Attachment/attachmentHelpers";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import type { User } from "@/types/proto/api/v1/user_service_pb";
import { type AttachmentVisualItem, buildAttachmentVisualItems, countLogicalAttachmentItems } from "@/utils/media-item";
import { getMemoSharePreviewAvatarUrl } from "./memoShareImage";
interface BuildMemoShareImagePreviewModelOptions {
memo: Memo;
creator?: User;
fallbackDisplayName: string;
locale: string;
}
export interface MemoShareImageAttachmentSummaryBadge {
type: "attachment-summary";
count: number;
}
export type MemoShareImageFooterBadge = MemoShareImageAttachmentSummaryBadge;
export interface MemoShareImagePreviewModel {
displayName: string;
avatarUrl?: string;
formattedDisplayTime?: string;
visualItems: AttachmentVisualItem[];
footerBadges: MemoShareImageFooterBadge[];
}
export const buildMemoShareImagePreviewModel = ({
memo,
creator,
fallbackDisplayName,
locale,
}: BuildMemoShareImagePreviewModelOptions): MemoShareImagePreviewModel => {
const displayName = creator?.displayName || creator?.username || fallbackDisplayName;
const avatarUrl = getMemoSharePreviewAvatarUrl(creator?.avatarUrl);
const displayTime = memo.displayTime ? timestampDate(memo.displayTime) : memo.createTime ? timestampDate(memo.createTime) : undefined;
const formattedDisplayTime = displayTime?.toLocaleString(locale, {
dateStyle: "medium",
timeStyle: "short",
});
const attachmentGroups = separateAttachments(memo.attachments);
const visualItems = buildAttachmentVisualItems(attachmentGroups.visual);
const attachmentCount = countLogicalAttachmentItems(memo.attachments);
const nonVisualAttachmentCount = Math.max(attachmentCount - visualItems.length, 0);
const footerBadges: MemoShareImageFooterBadge[] =
nonVisualAttachmentCount > 0 ? [{ type: "attachment-summary", count: attachmentCount }] : [];
return {
displayName,
avatarUrl,
formattedDisplayTime,
visualItems,
footerBadges,
};
};
import { create } from "@bufbuild/protobuf";
import { describe, expect, it } from "vitest";
import {
buildMemoShareImageFileName,
getMemoShareDialogWidth,
getMemoSharePreviewAvatarUrl,
getMemoSharePreviewWidth,
getMemoShareRenderWidth,
} from "@/components/MemoActionMenu/memoShareImage";
import { buildMemoShareImagePreviewModel } from "@/components/MemoActionMenu/memoShareImagePreviewModel";
import { AttachmentSchema, type Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { MemoSchema, type Memo } from "@/types/proto/api/v1/memo_service_pb";
const buildMemo = (overrides: Partial<Memo> = {}) =>
create(MemoSchema, {
name: "memos/test",
content: "hello",
tags: [],
attachments: [],
...overrides,
});
const buildAttachment = (overrides: Partial<Attachment>) =>
create(AttachmentSchema, {
name: "attachments/test",
filename: "test.bin",
type: "application/octet-stream",
...overrides,
});
const buildPreviewModel = (memo: Memo) =>
buildMemoShareImagePreviewModel({
memo,
fallbackDisplayName: "Memo",
locale: "en-US",
});
describe("memo share image preview model", () => {
it("does not create footer chips for memo tags already rendered in content", () => {
const memo = buildMemo({
content: "Investigate #bug",
tags: ["bug"],
});
const model = buildPreviewModel(memo);
expect(model.footerBadges).toEqual([]);
});
it("keeps non-visual attachments visible as a footer summary", () => {
const memo = buildMemo({
attachments: [
buildAttachment({
name: "attachments/doc",
filename: "doc.pdf",
type: "application/pdf",
}),
],
});
const model = buildPreviewModel(memo);
expect(model.visualItems).toEqual([]);
expect(model.footerBadges).toEqual([{ type: "attachment-summary", count: 1 }]);
});
it("keeps visual attachments in the media grid without adding a footer summary", () => {
const memo = buildMemo({
attachments: [
buildAttachment({
name: "attachments/image",
filename: "image.png",
type: "image/png",
}),
],
});
const model = buildPreviewModel(memo);
expect(model.visualItems).toHaveLength(1);
expect(model.visualItems[0]?.posterUrl).toContain("/file/attachments/image/image.png?thumbnail=true");
expect(model.footerBadges).toEqual([]);
});
it("counts mixed visual and non-visual attachments in the summary", () => {
const memo = buildMemo({
attachments: [
buildAttachment({
name: "attachments/image",
filename: "image.png",
type: "image/png",
}),
buildAttachment({
name: "attachments/archive",
filename: "archive.zip",
type: "application/zip",
}),
],
});
const model = buildPreviewModel(memo);
expect(model.visualItems).toHaveLength(1);
expect(model.footerBadges).toEqual([{ type: "attachment-summary", count: 2 }]);
});
});
describe("memo share image utilities", () => {
it("builds filenames from memo resource names", () => {
expect(buildMemoShareImageFileName("memos/abc123")).toBe("memo-abc123.png");
expect(buildMemoShareImageFileName("")).toBe("memo-memo.png");
});
it("clamps preview and dialog widths", () => {
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1000 });
expect(getMemoSharePreviewWidth(100)).toBe(260);
expect(getMemoSharePreviewWidth(800)).toBe(520);
expect(getMemoShareDialogWidth(520)).toBe(600);
expect(getMemoShareRenderWidth(520, 600)).toBe(560);
});
it("uses the viewport when no card width is available", () => {
Object.defineProperty(window, "innerWidth", { configurable: true, value: 400 });
expect(getMemoSharePreviewWidth(0)).toBe(317);
});
it("keeps only exportable avatar URLs", () => {
expect(getMemoSharePreviewAvatarUrl("/avatars/a.png")).toBe("/avatars/a.png");
expect(getMemoSharePreviewAvatarUrl("data:image/png;base64,abc")).toBe("data:image/png;base64,abc");
expect(getMemoSharePreviewAvatarUrl("https://example.com/avatar.png")).toBeUndefined();
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment